Merge branch 'master' into age-withdraw

This commit is contained in:
Özgür Kesim 2023-09-09 07:34:11 +02:00
commit 5495551071
Signed by: oec
GPG Key ID: 3D76A56D79EDD9D7
260 changed files with 9244 additions and 6140 deletions

33
.vscode/tasks.json vendored
View File

@ -1,18 +1,17 @@
{ {
// See https://go.microsoft.com/fwlink/?LinkId=733558 // See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format // for the documentation about the tasks.json format
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"type": "typescript", "type": "typescript",
"tsconfig": "tsconfig.build.json", "tsconfig": "tsconfig.build.json",
"problemMatcher": [ "problemMatcher": ["$tsc"],
"$tsc" "group": {
], "kind": "build",
"group": { "isDefault": true
"kind": "build", },
"isDefault": true, "label": "tsc: build - tsconfig.build.json"
}, }
} ]
] }
}

@ -1 +1 @@
Subproject commit 23538677f6c6be2a62f38dc6137ecdd1c76b7b15 Subproject commit 001f5dd081fc8729ff8def90c4a1c3f93eb8689a

View File

@ -6,6 +6,7 @@ RUN apt-get update -yq && \
apt-get install -yqq \ apt-get install -yqq \
git \ git \
python3 \ python3 \
codespell \
python3-distutils \ python3-distutils \
make \ make \
zip \ zip \

View File

@ -42,3 +42,4 @@ ths
updateing updateing
wan wan
wih wih
vie

View File

@ -3,4 +3,4 @@ set -exuo pipefail
job_dir=$(dirname "${BASH_SOURCE[0]}") job_dir=$(dirname "${BASH_SOURCE[0]}")
codespell -I "${job_dir}"/dictionary.txt -S "*.bib,*.bst,*.cls,*.json,*.png,*.svg,*.wav,*.gz,*/templating/test?/**,**/auditor/*.sql,**/templating/mustach**,*.fees,*key,*.tag,*.info,*.latexmkrc,*.ecc,*.jpg,*.zkey,*.sqlite,*/contrib/hellos/**,*/vpn/tests/**,*.priv,*.file,*.tgz,*.woff,*.gif,*.odt,*.fee,*.deflate,*.dat,*.jpeg,*.eps,*.odg,*/m4/ax_lib_postgresql.m4,*/m4/libgcrypt.m4,*.rpath,config.status,ABOUT-NLS,*/doc/texinfo.tex,*.PNG,*.??.json,*.docx,*.ods,*.doc,*.docx,*.xcf,*.xlsx,*.ecc,*.ttf,*.woff2,*.eot,*.ttf,*.eot,*.mp4,*.pptx,*.epgz,*.min.js,**/*.map,**/fonts/**,*.pack.js,*.po,*.bbl,*/afl-tests/*,*/.git/**,*.pdf,*.epub,**/signing-key.asc,**/pnpm-lock.yaml,**/*.svg,**/*.cls,**/rfc.bib,**/*.bst,*/cbdc-es.tex,*/cbdc-it.tex,**/ExchangeSelection/example.ts,*/testcurl/test_tricky.c,*/i18n/strings.ts,*/src/anastasis-data.ts,**/doc/flows/main.de.tex" codespell -I "${job_dir}"/dictionary.txt -S "*.bib,*.bst,*.cls,*.json,*.png,*.svg,*.wav,*.gz,*/templating/test?/**,**/auditor/*.sql,**/templating/mustach**,*.fees,*key,*.tag,*.info,*.latexmkrc,*.ecc,*.jpg,*.zkey,*.sqlite,*/contrib/hellos/**,*/vpn/tests/**,*.priv,*.file,*.tgz,*.woff,*.gif,*.odt,*.fee,*.deflate,*.dat,*.jpeg,*.eps,*.odg,*/m4/ax_lib_postgresql.m4,*/m4/libgcrypt.m4,*.rpath,config.status,ABOUT-NLS,*/doc/texinfo.tex,*.PNG,*.??.json,*.docx,*.ods,*.doc,*.docx,*.xcf,*.xlsx,*.ecc,*.ttf,*.woff2,*.eot,*.ttf,*.eot,*.mp4,*.pptx,*.epgz,*.min.js,**/*.map,**/fonts/**,*.pack.js,*.po,*.bbl,*/afl-tests/*,*/.git/**,*.pdf,*.epub,**/signing-key.asc,**/pnpm-lock.yaml,**/*.svg,**/*.cls,**/rfc.bib,**/*.bst,*/cbdc-es.tex,*/cbdc-it.tex,**/ExchangeSelection/example.ts,*/testcurl/test_tricky.c,*/i18n/strings.ts,*/src/anastasis-data.ts,**/doc/flows/main.de.tex,*/vendor/**"

View File

@ -15,6 +15,7 @@
"eslint": "^8.29.0", "eslint": "^8.29.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"nx": "15.0.1", "nx": "15.0.1",
"prettier": "^2.8.8" "prettier": "^2.8.8",
"typescript": "^5.2.2"
} }
} }

View File

@ -59,7 +59,7 @@
"postcss": "^8.4.23", "postcss": "^8.4.23",
"postcss-cli": "^10.1.0", "postcss-cli": "^10.1.0",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "5.1.3" "typescript": "5.2.2"
}, },
"pogen": { "pogen": {
"domain": "aml-backoffice" "domain": "aml-backoffice"

View File

@ -1,12 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
/* Basic Options */ /* Basic Options */
"target": "ES5", "target": "ES2020",
"module": "ES6", "module": "Node16",
"lib": [ "lib": ["DOM", "ES2020"],
"DOM",
"ES2017"
],
"allowJs": true /* Allow javascript files to be compiled. */, "allowJs": true /* Allow javascript files to be compiled. */,
// "checkJs": true, /* Report errors in .js files. */ // "checkJs": true, /* Report errors in .js files. */
"jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
@ -45,7 +42,5 @@
/* Advanced Options */ /* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */ "skipLibCheck": true /* Skip type checking of declaration files. */
}, },
"include": [ "include": ["src/**/*"]
"src/**/*" }
]
}

View File

@ -33,12 +33,12 @@
"@types/node": "^18.11.17", "@types/node": "^18.11.17",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"typedoc": "^0.24.8", "typedoc": "^0.25.1",
"typescript": "^5.1.3" "typescript": "^5.2.2"
}, },
"dependencies": { "dependencies": {
"@gnu-taler/taler-util": "workspace:*",
"@gnu-taler/anastasis-core": "workspace:*", "@gnu-taler/anastasis-core": "workspace:*",
"@gnu-taler/taler-util": "workspace:*",
"tslib": "^2.5.3" "tslib": "^2.5.3"
} }
} }

View File

@ -2,11 +2,11 @@
"compileOnSave": true, "compileOnSave": true,
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"target": "ES2018", "target": "ES2020",
"module": "ESNext", "module": "Node16",
"moduleResolution": "Node16", "moduleResolution": "Node16",
"sourceMap": true, "sourceMap": true,
"lib": ["es6"], "lib": ["ES2020"],
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"strict": true, "strict": true,

View File

@ -18,7 +18,7 @@
"devDependencies": { "devDependencies": {
"ava": "^4.3.3", "ava": "^4.3.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"typescript": "^5.1.3" "typescript": "^5.2.2"
}, },
"dependencies": { "dependencies": {
"@gnu-taler/taler-util": "workspace:*", "@gnu-taler/taler-util": "workspace:*",

View File

@ -138,7 +138,6 @@ export * as validators from "./validators.js";
export * from "./challenge-feedback-types.js"; export * from "./challenge-feedback-types.js";
const httpLib = createPlatformHttpLib({ const httpLib = createPlatformHttpLib({
allowHttp: true,
enableThrottling: false, enableThrottling: false,
}); });

View File

@ -3,7 +3,7 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"target": "ES2020", "target": "ES2020",
"module": "ESNext", "module": "Node16",
"moduleResolution": "Node16", "moduleResolution": "Node16",
"sourceMap": true, "sourceMap": true,
"lib": ["ES2020"], "lib": ["ES2020"],

View File

@ -44,6 +44,6 @@
"chai": "^4.3.6", "chai": "^4.3.6",
"mocha": "^9.2.0", "mocha": "^9.2.0",
"sass": "1.56.1", "sass": "1.56.1",
"typescript": "^5.1.3" "typescript": "^5.2.2"
} }
} }

View File

@ -1,11 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
/* Basic Options */ /* Basic Options */
"target": "ES5", "target": "ES2020",
"module": "ES6", "module": "Node16",
"lib": [ "lib": [
"DOM", "DOM",
"ES2017" "ES2020"
], ],
"allowJs": true /* Allow javascript files to be compiled. */, "allowJs": true /* Allow javascript files to be compiled. */,
// "checkJs": true, /* Report errors in .js files. */ // "checkJs": true, /* Report errors in .js files. */

View File

@ -60,7 +60,7 @@
"po2json": "^0.4.5", "po2json": "^0.4.5",
"preact-render-to-string": "^5.2.6", "preact-render-to-string": "^5.2.6",
"sass": "1.56.1", "sass": "1.56.1",
"typescript": "5.1.3" "typescript": "5.2.2"
}, },
"pogen": { "pogen": {
"domain": "bank" "domain": "bank"

View File

@ -1,12 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
/* Basic Options */ /* Basic Options */
"target": "ES5", "target": "ES2020",
"module": "ES6", "module": "Node16",
"lib": [ "lib": ["DOM", "ES2020"],
"DOM",
"ES2016"
],
"allowJs": true /* Allow javascript files to be compiled. */, "allowJs": true /* Allow javascript files to be compiled. */,
// "checkJs": true, /* Report errors in .js files. */ // "checkJs": true, /* Report errors in .js files. */
"jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
@ -45,7 +42,5 @@
/* Advanced Options */ /* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */ "skipLibCheck": true /* Skip type checking of declaration files. */
}, },
"include": [ "include": ["src/**/*"]
"src/**/*" }
]
}

View File

@ -29,7 +29,7 @@
"ava": "^5.3.1", "ava": "^5.3.1",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"typescript": "^5.1.6" "typescript": "^5.2.2"
}, },
"dependencies": { "dependencies": {
"tslib": "^2.6.0" "tslib": "^2.6.0"

View File

@ -1882,7 +1882,7 @@ export class SqliteBackend implements Backend {
} }
} }
clearObjectStore( async clearObjectStore(
btx: DatabaseTransaction, btx: DatabaseTransaction,
objectStoreName: string, objectStoreName: string,
): Promise<void> { ): Promise<void> {
@ -1906,7 +1906,21 @@ export class SqliteBackend implements Backend {
); );
} }
throw new Error("Method not implemented."); this._prep(sqlClearObjectStore).run({
object_store_id: scopeInfo.objectStoreId,
});
for (const index of scopeInfo.indexMap.values()) {
let stmt: Sqlite3Statement;
if (index.unique) {
stmt = this._prep(sqlClearUniqueIndexData);
} else {
stmt = this._prep(sqlClearIndexData);
}
stmt.run({
index_id: index.indexId,
});
}
} }
} }
@ -1963,6 +1977,15 @@ CREATE TABLE IF NOT EXISTS unique_index_data
); );
`; `;
const sqlClearObjectStore = `
DELETE FROM object_data WHERE object_store_id=$object_store_id`;
const sqlClearIndexData = `
DELETE FROM index_data WHERE index_id=$index_id`;
const sqlClearUniqueIndexData = `
DELETE FROM unique_index_data WHERE index_id=$index_id`;
const sqlListDatabases = ` const sqlListDatabases = `
SELECT name, version FROM databases; SELECT name, version FROM databases;
`; `;

View File

@ -144,7 +144,7 @@ export interface IndexMeta {
unique: boolean; unique: boolean;
} }
// FIXME: Instead of refering to an object store by name, // FIXME: Instead of referring to an object store by name,
// maybe refer to it via some internal, numeric ID? // maybe refer to it via some internal, numeric ID?
// This would simplify renaming. // This would simplify renaming.
export interface Backend { export interface Backend {

View File

@ -735,7 +735,9 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
} }
if (this._closePending) { if (this._closePending) {
throw new InvalidStateError(); throw new InvalidStateError(
`tried to start transaction on ${this._name}, but a close is pending`,
);
} }
if (!Array.isArray(storeNames)) { if (!Array.isArray(storeNames)) {
@ -930,6 +932,9 @@ export class BridgeIDBFactory {
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#dfn-steps-for-running-a-versionchange-transaction // http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#dfn-steps-for-running-a-versionchange-transaction
for (const otherConn of this.connections) { for (const otherConn of this.connections) {
if (otherConn._name != db._name) {
continue;
}
if (otherConn._closePending) { if (otherConn._closePending) {
continue; continue;
} }

View File

@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"lib": ["es6"], "lib": ["ES2020"],
"module": "ES2020", "module": "Node16",
"moduleResolution": "Node16", "moduleResolution": "Node16",
"target": "ES2020", "target": "ES2020",
"allowJs": true, "allowJs": true,

View File

@ -65,6 +65,6 @@
"sirv-cli": "^1.0.11", "sirv-cli": "^1.0.11",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tslib": "2.5.3", "tslib": "2.5.3",
"typescript": "5.1.3" "typescript": "5.2.2"
} }
} }

View File

@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "@gnu-taler/merchant-backoffice-ui", "name": "@gnu-taler/merchant-backoffice-ui",
"version": "0.0.5", "version": "0.1.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -41,7 +41,7 @@
"preact": "10.11.3", "preact": "10.11.3",
"preact-router": "3.2.1", "preact-router": "3.2.1",
"qrcode-generator": "1.4.4", "qrcode-generator": "1.4.4",
"swr": "1.3.0", "swr": "2.2.2",
"yup": "^0.32.9" "yup": "^0.32.9"
}, },
"devDependencies": { "devDependencies": {
@ -75,10 +75,10 @@
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "1.56.1", "sass": "1.56.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"typedoc": "^0.24.8", "typedoc": "^0.25.1",
"typescript": "5.1.3" "typescript": "5.2.2"
}, },
"pogen": { "pogen": {
"domain": "taler-merchant-backoffice" "domain": "taler-merchant-backoffice"
} }
} }

View File

@ -19,19 +19,20 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { HttpStatusCode, LibtoolVersion } from "@gnu-taler/taler-util";
import { import {
ErrorType, ErrorType,
TranslationProvider, TranslationProvider,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { route } from "preact-router"; import { route } from "preact-router";
import { useMemo, useState } from "preact/hooks"; import { useMemo } from "preact/hooks";
import { ApplicationReadyRoutes } from "./ApplicationReadyRoutes.js"; import { ApplicationReadyRoutes } from "./ApplicationReadyRoutes.js";
import { Loading } from "./components/exception/loading.js"; import { Loading } from "./components/exception/loading.js";
import { import {
NotificationCard, NotConnectedAppMenu,
NotYetReadyAppMenu, NotificationCard
} from "./components/menu/index.js"; } from "./components/menu/index.js";
import { import {
BackendContextProvider, BackendContextProvider,
@ -41,23 +42,24 @@ import { ConfigContextProvider } from "./context/config.js";
import { useBackendConfig } from "./hooks/backend.js"; import { useBackendConfig } from "./hooks/backend.js";
import { strings } from "./i18n/strings.js"; import { strings } from "./i18n/strings.js";
import LoginPage from "./paths/login/index.js"; import LoginPage from "./paths/login/index.js";
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { Settings } from "./paths/settings/index.js";
export function Application(): VNode { export function Application(): VNode {
return ( return (
// <FetchContextProvider>
<BackendContextProvider> <BackendContextProvider>
<TranslationProvider source={strings}> <TranslationProvider source={strings}>
<ApplicationStatusRoutes /> <ApplicationStatusRoutes />
</TranslationProvider> </TranslationProvider>
</BackendContextProvider> </BackendContextProvider>
// </FetchContextProvider>
); );
} }
/**
* Check connection testing against /config
*
* @returns
*/
function ApplicationStatusRoutes(): VNode { function ApplicationStatusRoutes(): VNode {
const { updateLoginStatus, triedToLog } = useBackendContext(); const { url, updateLoginStatus, triedToLog } = useBackendContext();
const result = useBackendConfig(); const result = useBackendConfig();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -71,19 +73,10 @@ function ApplicationStatusRoutes(): VNode {
: { currency: "unknown", version: "unknown" }; : { currency: "unknown", version: "unknown" };
const ctx = useMemo(() => ({ currency, version }), [currency, version]); const ctx = useMemo(() => ({ currency, version }), [currency, version]);
const [showSettings, setShowSettings] = useState(false)
if (showSettings) {
return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" />
<Settings />
</Fragment>
}
if (!triedToLog) { if (!triedToLog) {
return ( return (
<Fragment> <Fragment>
<NotYetReadyAppMenu title="Welcome!" onShowSettings={() => setShowSettings(true)} /> <NotConnectedAppMenu title="Welcome!" />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
</Fragment> </Fragment>
); );
@ -97,7 +90,7 @@ function ApplicationStatusRoutes(): VNode {
) { ) {
return ( return (
<Fragment> <Fragment>
<NotYetReadyAppMenu title="Login" onShowSettings={() => setShowSettings(true)} /> <NotConnectedAppMenu title="Login" />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
</Fragment> </Fragment>
); );
@ -108,7 +101,7 @@ function ApplicationStatusRoutes(): VNode {
) { ) {
return ( return (
<Fragment> <Fragment>
<NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} /> <NotConnectedAppMenu title="Error" />
<NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Server not found`, message: i18n.str`Server not found`,
@ -122,7 +115,7 @@ function ApplicationStatusRoutes(): VNode {
} }
if (result.type === ErrorType.SERVER) { if (result.type === ErrorType.SERVER) {
<Fragment> <Fragment>
<NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} /> <NotConnectedAppMenu title="Error" />
<NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Server response with an error code`, message: i18n.str`Server response with an error code`,
@ -135,7 +128,7 @@ function ApplicationStatusRoutes(): VNode {
} }
if (result.type === ErrorType.UNREADABLE) { if (result.type === ErrorType.UNREADABLE) {
<Fragment> <Fragment>
<NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} /> <NotConnectedAppMenu title="Error" />
<NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Response from server is unreadable, http status: ${result.status}`, message: i18n.str`Response from server is unreadable, http status: ${result.status}`,
@ -148,7 +141,7 @@ function ApplicationStatusRoutes(): VNode {
} }
return ( return (
<Fragment> <Fragment>
<NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} /> <NotConnectedAppMenu title="Error" />
<NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Unexpected Error`, message: i18n.str`Unexpected Error`,
@ -161,6 +154,25 @@ function ApplicationStatusRoutes(): VNode {
); );
} }
const SUPPORTED_VERSION = "5:0:1"
if (!LibtoolVersion.compare(
SUPPORTED_VERSION,
result.data.version,
)?.compatible) {
return <Fragment>
<NotConnectedAppMenu title="Error" />
<NotificationCard
notification={{
message: i18n.str`Incompatible version`,
type: "ERROR",
description: i18n.str`Merchant backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`,
}}
/>
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
</Fragment>
}
return ( return (
<div class="has-navbar-fixed-top"> <div class="has-navbar-fixed-top">
<ConfigContextProvider value={ctx}> <ConfigContextProvider value={ctx}>

View File

@ -22,7 +22,7 @@ import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser";
import { createHashHistory } from "history"; import { createHashHistory } from "history";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { Router, Route, route } from "preact-router"; import { Router, Route, route } from "preact-router";
import { useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { import {
NotificationCard, NotificationCard,
NotYetReadyAppMenu, NotYetReadyAppMenu,
@ -35,52 +35,55 @@ import { INSTANCE_ID_LOOKUP } from "./utils/constants.js";
import { HttpStatusCode } from "@gnu-taler/taler-util"; import { HttpStatusCode } from "@gnu-taler/taler-util";
import { Settings } from "./paths/settings/index.js"; import { Settings } from "./paths/settings/index.js";
/**
* Check if admin against /management/instances
* @returns
*/
export function ApplicationReadyRoutes(): VNode { export function ApplicationReadyRoutes(): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [unauthorized, setUnauthorized] = useState(false)
const { const {
url: backendURL, url: backendURL,
updateLoginStatus, updateLoginStatus: updateLoginStatus2,
clearAllTokens,
} = useBackendContext(); } = useBackendContext();
function updateLoginStatus(url: string, token: string | undefined) {
console.log("updateing", url, token)
updateLoginStatus2(url, token)
setUnauthorized(false)
}
const result = useBackendInstancesTestForAdmin(); const result = useBackendInstancesTestForAdmin();
const clearTokenAndGoToRoot = () => { const clearTokenAndGoToRoot = () => {
clearAllTokens();
route("/"); route("/");
}; };
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
// useEffect(() => {
// setUnauthorized(FF)
// }, [FF])
const unauthorizedAdmin = !result.loading && !result.ok && result.type === ErrorType.CLIENT && result.status === HttpStatusCode.Unauthorized
if (showSettings) { if (showSettings) {
return <Fragment> return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} /> <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
<Settings/> <Settings />
</Fragment> </Fragment>
} }
if (result.loading) return <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Loading..." />;
let admin = true; if (result.loading) {
let instanceNameByBackendURL; return <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Loading..." isPasswordOk={false} />;
}
if (!result.ok) { let admin = result.ok || unauthorizedAdmin;
if ( let instanceNameByBackendURL: string | undefined;
result.type === ErrorType.CLIENT &&
result.status === HttpStatusCode.Unauthorized if (!admin) {
) { // * the testing against admin endpoint failed and it's not
return ( // an authorization problem
<Fragment> // * merchant backend will return this SPA under the main
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} /> // endpoint or /instance/<id> endpoint
<NotificationCard // => trying to infer the instance id
notification={{
message: i18n.str`Access denied`,
description: i18n.str`Check your token is valid`,
type: "ERROR",
}}
/>
<LoginPage onConfirm={updateLoginStatus} />
</Fragment>
);
}
const path = new URL(backendURL).pathname; const path = new URL(backendURL).pathname;
const match = INSTANCE_ID_LOOKUP.exec(path); const match = INSTANCE_ID_LOOKUP.exec(path);
if (!match || !match[1]) { if (!match || !match[1]) {
@ -89,7 +92,7 @@ export function ApplicationReadyRoutes(): VNode {
// does not match our pattern // does not match our pattern
return ( return (
<Fragment> <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Error" onLogout={clearTokenAndGoToRoot} /> <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Error" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
<NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Couldn't access the server.`, message: i18n.str`Couldn't access the server.`,
@ -102,10 +105,24 @@ export function ApplicationReadyRoutes(): VNode {
); );
} }
admin = false;
instanceNameByBackendURL = match[1]; instanceNameByBackendURL = match[1];
} }
console.log(unauthorized, unauthorizedAdmin)
if (unauthorized || unauthorizedAdmin) {
return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
<NotificationCard
notification={{
message: i18n.str`Access denied`,
description: i18n.str`Check your token is valid`,
type: "ERROR",
}}
/>
<LoginPage onConfirm={updateLoginStatus} />
</Fragment>
}
const history = createHashHistory(); const history = createHashHistory();
return ( return (
<Router history={history}> <Router history={history}>
@ -113,6 +130,11 @@ export function ApplicationReadyRoutes(): VNode {
default default
component={DefaultMainRoute} component={DefaultMainRoute}
admin={admin} admin={admin}
onUnauthorized={() => setUnauthorized(true)}
onLoginPass={() => {
console.log("ahora si")
setUnauthorized(false)
}}
instanceNameByBackendURL={instanceNameByBackendURL} instanceNameByBackendURL={instanceNameByBackendURL}
/> />
</Router> </Router>
@ -122,6 +144,8 @@ export function ApplicationReadyRoutes(): VNode {
function DefaultMainRoute({ function DefaultMainRoute({
instance, instance,
admin, admin,
onUnauthorized,
onLoginPass,
instanceNameByBackendURL, instanceNameByBackendURL,
url, //from preact-router url, //from preact-router
}: any): VNode { }: any): VNode {
@ -133,6 +157,8 @@ function DefaultMainRoute({
<InstanceRoutes <InstanceRoutes
admin={admin} admin={admin}
path={url} path={url}
onUnauthorized={onUnauthorized}
onLoginPass={onLoginPass}
id={instanceName} id={instanceName}
setInstanceName={setInstanceName} setInstanceName={setInstanceName}
/> />

View File

@ -40,6 +40,7 @@ import {
import { useInstanceKYCDetails } from "./hooks/instance.js"; import { useInstanceKYCDetails } from "./hooks/instance.js";
import InstanceCreatePage from "./paths/admin/create/index.js"; import InstanceCreatePage from "./paths/admin/create/index.js";
import InstanceListPage from "./paths/admin/list/index.js"; import InstanceListPage from "./paths/admin/list/index.js";
import TokenPage from "./paths/instance/token/index.js";
import ListKYCPage from "./paths/instance/kyc/list/index.js"; import ListKYCPage from "./paths/instance/kyc/list/index.js";
import OrderCreatePage from "./paths/instance/orders/create/index.js"; import OrderCreatePage from "./paths/instance/orders/create/index.js";
import OrderDetailsPage from "./paths/instance/orders/details/index.js"; import OrderDetailsPage from "./paths/instance/orders/details/index.js";
@ -47,6 +48,9 @@ import OrderListPage from "./paths/instance/orders/list/index.js";
import ProductCreatePage from "./paths/instance/products/create/index.js"; import ProductCreatePage from "./paths/instance/products/create/index.js";
import ProductListPage from "./paths/instance/products/list/index.js"; import ProductListPage from "./paths/instance/products/list/index.js";
import ProductUpdatePage from "./paths/instance/products/update/index.js"; import ProductUpdatePage from "./paths/instance/products/update/index.js";
import BankAccountCreatePage from "./paths/instance/accounts/create/index.js";
import BankAccountListPage from "./paths/instance/accounts/list/index.js";
import BankAccountUpdatePage from "./paths/instance/accounts/update/index.js";
import ReservesCreatePage from "./paths/instance/reserves/create/index.js"; import ReservesCreatePage from "./paths/instance/reserves/create/index.js";
import ReservesDetailsPage from "./paths/instance/reserves/details/index.js"; import ReservesDetailsPage from "./paths/instance/reserves/details/index.js";
import ReservesListPage from "./paths/instance/reserves/list/index.js"; import ReservesListPage from "./paths/instance/reserves/list/index.js";
@ -58,6 +62,9 @@ import TemplateUpdatePage from "./paths/instance/templates/update/index.js";
import WebhookCreatePage from "./paths/instance/webhooks/create/index.js"; import WebhookCreatePage from "./paths/instance/webhooks/create/index.js";
import WebhookListPage from "./paths/instance/webhooks/list/index.js"; import WebhookListPage from "./paths/instance/webhooks/list/index.js";
import WebhookUpdatePage from "./paths/instance/webhooks/update/index.js"; import WebhookUpdatePage from "./paths/instance/webhooks/update/index.js";
import ValidatorCreatePage from "./paths/instance/validators/create/index.js";
import ValidatorListPage from "./paths/instance/validators/list/index.js";
import ValidatorUpdatePage from "./paths/instance/validators/update/index.js";
import TransferCreatePage from "./paths/instance/transfers/create/index.js"; import TransferCreatePage from "./paths/instance/transfers/create/index.js";
import TransferListPage from "./paths/instance/transfers/list/index.js"; import TransferListPage from "./paths/instance/transfers/list/index.js";
import InstanceUpdatePage, { import InstanceUpdatePage, {
@ -69,11 +76,16 @@ import NotFoundPage from "./paths/notfound/index.js";
import { Notification } from "./utils/types.js"; import { Notification } from "./utils/types.js";
import { MerchantBackend } from "./declaration.js"; import { MerchantBackend } from "./declaration.js";
import { Settings } from "./paths/settings/index.js"; import { Settings } from "./paths/settings/index.js";
import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js";
export enum InstancePaths { export enum InstancePaths {
// details = '/',
error = "/error", error = "/error",
update = "/update", server = "/server",
token = "/token",
bank_list = "/bank",
bank_update = "/bank/:bid/update",
bank_new = "/bank/new",
product_list = "/products", product_list = "/products",
product_update = "/product/:pid/update", product_update = "/product/:pid/update",
@ -102,11 +114,15 @@ export enum InstancePaths {
webhooks_update = "/webhooks/:tid/update", webhooks_update = "/webhooks/:tid/update",
webhooks_new = "/webhooks/new", webhooks_new = "/webhooks/new",
settings = "/settings", validators_list = "/validators",
validators_update = "/validators/:vid/update",
validators_new = "/validators/new",
settings = "/interface",
} }
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {}; const noop = () => { };
export enum AdminPaths { export enum AdminPaths {
list_instances = "/instances", list_instances = "/instances",
@ -118,6 +134,8 @@ export interface Props {
id: string; id: string;
admin?: boolean; admin?: boolean;
path: string; path: string;
onUnauthorized: () => void;
onLoginPass: () => void;
setInstanceName: (s: string) => void; setInstanceName: (s: string) => void;
} }
@ -125,40 +143,29 @@ export function InstanceRoutes({
id, id,
admin, admin,
path, path,
onUnauthorized,
onLoginPass,
setInstanceName, setInstanceName,
}: Props): VNode { }: Props): VNode {
const [_, updateDefaultToken] = useBackendDefaultToken(); const [defaultToken, updateDefaultToken] = useBackendDefaultToken();
const [token, updateToken] = useBackendInstanceToken(id); const [token, updateToken] = useBackendInstanceToken(id);
const {
updateLoginStatus: changeBackend,
addTokenCleaner,
clearAllTokens,
} = useBackendContext();
const cleaner = useCallback(() => {
updateToken(undefined);
}, [id]);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
type GlobalNotifState = (Notification & { to: string }) | undefined; type GlobalNotifState = (Notification & { to: string }) | undefined;
const [globalNotification, setGlobalNotification] = const [globalNotification, setGlobalNotification] =
useState<GlobalNotifState>(undefined); useState<GlobalNotifState>(undefined);
useEffect(() => {
addTokenCleaner(cleaner);
}, [addTokenCleaner, cleaner]);
const changeToken = (token?: string) => { const changeToken = (token?: string) => {
if (admin) { if (admin) {
updateToken(token); updateToken(token);
} else { } else {
updateDefaultToken(token); updateDefaultToken(token);
} }
onLoginPass()
}; };
const updateLoginStatus = (url: string, token?: string) => { // const updateLoginStatus = (url: string, token?: string) => {
changeBackend(url); // changeToken(token);
if (!token) return; // };
changeToken(token);
};
const value = useMemo( const value = useMemo(
() => ({ id, token, admin, changeToken }), () => ({ id, token, admin, changeToken }),
@ -192,18 +199,17 @@ export function InstanceRoutes({
}; };
} }
const LoginPageAccessDenied = () => ( // const LoginPageAccessDeniend = onUnauthorized
<Fragment> const LoginPageAccessDenied = () => {
<NotificationCard onUnauthorized()
notification={{ return <NotificationCard
message: i18n.str`Access denied`, notification={{
description: i18n.str`The access token provided is invalid.`, message: i18n.str`Access denied`,
type: "ERROR", description: i18n.str`Redirecting to login page.`,
}} type: "ERROR",
/> }}
<LoginPage onConfirm={updateLoginStatus} /> />
</Fragment> }
);
function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) { function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) {
return function IfAdminCreateDefaultOrImpl(props?: T) { return function IfAdminCreateDefaultOrImpl(props?: T) {
@ -234,8 +240,10 @@ export function InstanceRoutes({
} }
const clearTokenAndGoToRoot = () => { const clearTokenAndGoToRoot = () => {
clearAllTokens();
route("/"); route("/");
// clear all tokens
updateToken(undefined)
updateDefaultToken(undefined)
}; };
return ( return (
@ -244,11 +252,12 @@ export function InstanceRoutes({
instance={id} instance={id}
admin={admin} admin={admin}
onShowSettings={() => { onShowSettings={() => {
route("/settings") route(InstancePaths.settings)
}} }}
path={path} path={path}
onLogout={clearTokenAndGoToRoot} onLogout={clearTokenAndGoToRoot}
setInstanceName={setInstanceName} setInstanceName={setInstanceName}
isPasswordOk={defaultToken !== undefined}
/> />
<KycBanner /> <KycBanner />
<NotificationCard notification={globalNotification} /> <NotificationCard notification={globalNotification} />
@ -308,7 +317,7 @@ export function InstanceRoutes({
* Update instance page * Update instance page
*/} */}
<Route <Route
path={InstancePaths.update} path={InstancePaths.server}
component={InstanceUpdatePage} component={InstanceUpdatePage}
onBack={() => { onBack={() => {
route(`/`); route(`/`);
@ -321,6 +330,19 @@ export function InstanceRoutes({
onUnauthorized={LoginPageAccessDenied} onUnauthorized={LoginPageAccessDenied}
onLoadError={ServerErrorRedirectTo(InstancePaths.error)} onLoadError={ServerErrorRedirectTo(InstancePaths.error)}
/> />
{/**
* Update instance page
*/}
<Route
path={InstancePaths.token}
component={TokenPage}
onChange={() => {
route(`/`);
}}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onUnauthorized={LoginPageAccessDenied}
onLoadError={ServerErrorRedirectTo(InstancePaths.error)}
/>
{/** {/**
* Product pages * Product pages
*/} */}
@ -328,7 +350,7 @@ export function InstanceRoutes({
path={InstancePaths.product_list} path={InstancePaths.product_list}
component={ProductListPage} component={ProductListPage}
onUnauthorized={LoginPageAccessDenied} onUnauthorized={LoginPageAccessDenied}
onLoadError={ServerErrorRedirectTo(InstancePaths.update)} onLoadError={ServerErrorRedirectTo(InstancePaths.server)}
onCreate={() => { onCreate={() => {
route(InstancePaths.product_new); route(InstancePaths.product_new);
}} }}
@ -360,6 +382,45 @@ export function InstanceRoutes({
route(InstancePaths.product_list); route(InstancePaths.product_list);
}} }}
/> />
{/**
* Bank pages
*/}
<Route
path={InstancePaths.bank_list}
component={BankAccountListPage}
onUnauthorized={LoginPageAccessDenied}
onLoadError={ServerErrorRedirectTo(InstancePaths.server)}
onCreate={() => {
route(InstancePaths.bank_new);
}}
onSelect={(id: string) => {
route(InstancePaths.bank_update.replace(":bid", id));
}}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
/>
<Route
path={InstancePaths.bank_update}
component={BankAccountUpdatePage}
onUnauthorized={LoginPageAccessDenied}
onLoadError={ServerErrorRedirectTo(InstancePaths.product_list)}
onConfirm={() => {
route(InstancePaths.bank_list);
}}
onBack={() => {
route(InstancePaths.bank_list);
}}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
/>
<Route
path={InstancePaths.bank_new}
component={BankAccountCreatePage}
onConfirm={() => {
route(InstancePaths.bank_list);
}}
onBack={() => {
route(InstancePaths.bank_list);
}}
/>
{/** {/**
* Order pages * Order pages
*/} */}
@ -373,7 +434,7 @@ export function InstanceRoutes({
route(InstancePaths.order_details.replace(":oid", id)); route(InstancePaths.order_details.replace(":oid", id));
}} }}
onUnauthorized={LoginPageAccessDenied} onUnauthorized={LoginPageAccessDenied}
onLoadError={ServerErrorRedirectTo(InstancePaths.update)} onLoadError={ServerErrorRedirectTo(InstancePaths.server)}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
/> />
<Route <Route
@ -389,8 +450,8 @@ export function InstanceRoutes({
<Route <Route
path={InstancePaths.order_new} path={InstancePaths.order_new}
component={OrderCreatePage} component={OrderCreatePage}
onConfirm={() => { onConfirm={(orderId: string) => {
route(InstancePaths.order_list); route(InstancePaths.order_details.replace(":oid", orderId));
}} }}
onBack={() => { onBack={() => {
route(InstancePaths.order_list); route(InstancePaths.order_list);
@ -404,7 +465,7 @@ export function InstanceRoutes({
component={TransferListPage} component={TransferListPage}
onUnauthorized={LoginPageAccessDenied} onUnauthorized={LoginPageAccessDenied}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onLoadError={ServerErrorRedirectTo(InstancePaths.update)} onLoadError={ServerErrorRedirectTo(InstancePaths.server)}
onCreate={() => { onCreate={() => {
route(InstancePaths.transfers_new); route(InstancePaths.transfers_new);
}} }}
@ -427,7 +488,7 @@ export function InstanceRoutes({
component={WebhookListPage} component={WebhookListPage}
onUnauthorized={LoginPageAccessDenied} onUnauthorized={LoginPageAccessDenied}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onLoadError={ServerErrorRedirectTo(InstancePaths.update)} onLoadError={ServerErrorRedirectTo(InstancePaths.server)}
onCreate={() => { onCreate={() => {
route(InstancePaths.webhooks_new); route(InstancePaths.webhooks_new);
}} }}
@ -458,6 +519,45 @@ export function InstanceRoutes({
route(InstancePaths.webhooks_list); route(InstancePaths.webhooks_list);
}} }}
/> />
{/**
* Validator pages
*/}
<Route
path={InstancePaths.validators_list}
component={ValidatorListPage}
onUnauthorized={LoginPageAccessDenied}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onLoadError={ServerErrorRedirectTo(InstancePaths.server)}
onCreate={() => {
route(InstancePaths.validators_new);
}}
onSelect={(id: string) => {
route(InstancePaths.validators_update.replace(":vid", id));
}}
/>
<Route
path={InstancePaths.validators_update}
component={ValidatorUpdatePage}
onConfirm={() => {
route(InstancePaths.validators_list);
}}
onUnauthorized={LoginPageAccessDenied}
onLoadError={ServerErrorRedirectTo(InstancePaths.validators_list)}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onBack={() => {
route(InstancePaths.validators_list);
}}
/>
<Route
path={InstancePaths.validators_new}
component={ValidatorCreatePage}
onConfirm={() => {
route(InstancePaths.validators_list);
}}
onBack={() => {
route(InstancePaths.validators_list);
}}
/>
{/** {/**
* Templates pages * Templates pages
*/} */}
@ -466,7 +566,7 @@ export function InstanceRoutes({
component={TemplateListPage} component={TemplateListPage}
onUnauthorized={LoginPageAccessDenied} onUnauthorized={LoginPageAccessDenied}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onLoadError={ServerErrorRedirectTo(InstancePaths.update)} onLoadError={ServerErrorRedirectTo(InstancePaths.server)}
onCreate={() => { onCreate={() => {
route(InstancePaths.templates_new); route(InstancePaths.templates_new);
}} }}
@ -535,7 +635,7 @@ export function InstanceRoutes({
component={ReservesListPage} component={ReservesListPage}
onUnauthorized={LoginPageAccessDenied} onUnauthorized={LoginPageAccessDenied}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onLoadError={ServerErrorRedirectTo(InstancePaths.update)} onLoadError={ServerErrorRedirectTo(InstancePaths.server)}
onSelect={(id: string) => { onSelect={(id: string) => {
route(InstancePaths.reserves_details.replace(":rid", id)); route(InstancePaths.reserves_details.replace(":rid", id));
}} }}
@ -590,7 +690,7 @@ function AdminInstanceUpdatePage({
const { updateLoginStatus: changeBackend } = useBackendContext(); const { updateLoginStatus: changeBackend } = useBackendContext();
const updateLoginStatus = (url: string, token?: string): void => { const updateLoginStatus = (url: string, token?: string): void => {
changeBackend(url); changeBackend(url);
if (token) changeToken(token); changeToken(token);
}; };
const value = useMemo( const value = useMemo(
() => ({ id, token, admin: true, changeToken }), () => ({ id, token, admin: true, changeToken }),
@ -607,20 +707,20 @@ function AdminInstanceUpdatePage({
const notif = const notif =
error.type === ErrorType.TIMEOUT error.type === ErrorType.TIMEOUT
? { ? {
message: i18n.str`The request to the backend take too long and was cancelled`, message: i18n.str`The request to the backend take too long and was cancelled`,
description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`,
type: "ERROR" as const, type: "ERROR" as const,
} }
: { : {
message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, message: i18n.str`The backend reported a problem: HTTP status #${error.status}`,
description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`,
details: details:
error.type === ErrorType.CLIENT || error.type === ErrorType.CLIENT ||
error.type === ErrorType.SERVER error.type === ErrorType.SERVER
? error.payload.detail ? error.payload.detail
: undefined, : undefined,
type: "ERROR" as const, type: "ERROR" as const,
}; };
return ( return (
<Fragment> <Fragment>
<NotificationCard notification={notif} /> <NotificationCard notification={notif} />
@ -650,7 +750,8 @@ function AdminInstanceUpdatePage({
function KycBanner(): VNode { function KycBanner(): VNode {
const kycStatus = useInstanceKYCDetails(); const kycStatus = useInstanceKYCDetails();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const today = format(new Date(), "yyyy-MM-dd"); const [settings] = useSettings();
const today = format(new Date(), dateFormatForSettings(settings));
const [lastHide, setLastHide] = useLocalStorage("kyc-last-hide"); const [lastHide, setLastHide] = useLocalStorage("kyc-last-hide");
const hasBeenHidden = today === lastHide; const hasBeenHidden = today === lastHide;
const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect";

View File

@ -93,7 +93,7 @@ export function LoginModal({ onConfirm, withMessage }: Props): VNode {
<input <input
class="input" class="input"
type="password" type="password"
placeholder={"set new access token"} placeholder={"current access token"}
name="token" name="token"
onKeyPress={(e) => onKeyPress={(e) =>
e.keyCode === 13 e.keyCode === 13
@ -186,7 +186,7 @@ export function LoginModal({ onConfirm, withMessage }: Props): VNode {
<input <input
class="input" class="input"
type="password" type="password"
placeholder={"set new access token"} placeholder={"current access token"}
name="token" name="token"
onKeyPress={(e) => onKeyPress={(e) =>
e.keyCode === 13 e.keyCode === 13

View File

@ -20,16 +20,18 @@
*/ */
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { format } from "date-fns"; import { format } from "date-fns";
import { h, VNode } from "preact"; import { ComponentChildren, h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { DatePicker } from "../picker/DatePicker.js"; import { DatePicker } from "../picker/DatePicker.js";
import { InputProps, useField } from "./useField.js"; import { InputProps, useField } from "./useField.js";
import { dateFormatForSettings, useSettings } from "../../hooks/useSettings.js";
export interface Props<T> extends InputProps<T> { export interface Props<T> extends InputProps<T> {
readonly?: boolean; readonly?: boolean;
expand?: boolean; expand?: boolean;
//FIXME: create separated components InputDate and InputTimestamp //FIXME: create separated components InputDate and InputTimestamp
withTimestampSupport?: boolean; withTimestampSupport?: boolean;
side?: ComponentChildren;
} }
export function InputDate<T>({ export function InputDate<T>({
@ -41,9 +43,11 @@ export function InputDate<T>({
tooltip, tooltip,
expand, expand,
withTimestampSupport, withTimestampSupport,
side,
}: Props<keyof T>): VNode { }: Props<keyof T>): VNode {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings()
const { error, required, value, onChange } = useField<T>(name); const { error, required, value, onChange } = useField<T>(name);
@ -51,14 +55,14 @@ export function InputDate<T>({
if (!value) { if (!value) {
strValue = withTimestampSupport ? "unknown" : ""; strValue = withTimestampSupport ? "unknown" : "";
} else if (value instanceof Date) { } else if (value instanceof Date) {
strValue = format(value, "yyyy/MM/dd"); strValue = format(value, dateFormatForSettings(settings));
} else if (value.t_s) { } else if (value.t_s) {
strValue = strValue =
value.t_s === "never" value.t_s === "never"
? withTimestampSupport ? withTimestampSupport
? "never" ? "never"
: "" : ""
: format(new Date(value.t_s * 1000), "yyyy/MM/dd"); : format(new Date(value.t_s * 1000), dateFormatForSettings(settings));
} }
return ( return (
@ -142,6 +146,7 @@ export function InputDate<T>({
</button> </button>
</span> </span>
)} )}
{side}
</div> </div>
<DatePicker <DatePicker
opened={opened} opened={opened}

View File

@ -18,9 +18,9 @@
* *
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { parsePaytoUri, PaytoUriGeneric, stringifyPaytoUri } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useCallback, useState } from "preact/hooks";
import { COUNTRY_TABLE } from "../../utils/constants.js"; import { COUNTRY_TABLE } from "../../utils/constants.js";
import { undefinedIfEmpty } from "../../utils/table.js"; import { undefinedIfEmpty } from "../../utils/table.js";
import { FormErrors, FormProvider } from "./FormProvider.js"; import { FormErrors, FormProvider } from "./FormProvider.js";
@ -28,23 +28,23 @@ import { Input } from "./Input.js";
import { InputGroup } from "./InputGroup.js"; import { InputGroup } from "./InputGroup.js";
import { InputSelector } from "./InputSelector.js"; import { InputSelector } from "./InputSelector.js";
import { InputProps, useField } from "./useField.js"; import { InputProps, useField } from "./useField.js";
import { InputWithAddon } from "./InputWithAddon.js"; import { useEffect, useState } from "preact/hooks";
import { MerchantBackend } from "../../declaration.js";
export interface Props<T> extends InputProps<T> { export interface Props<T> extends InputProps<T> {
isValid?: (e: any) => boolean; isValid?: (e: any) => boolean;
} }
// type Entity = PaytoUriGeneric
// https://datatracker.ietf.org/doc/html/rfc8905 // https://datatracker.ietf.org/doc/html/rfc8905
type Entity = { type Entity = {
// iban, bitcoin, x-taler-bank. it defined the format // iban, bitcoin, x-taler-bank. it defined the format
target: string; target: string;
// path1 if the first field to be used // path1 if the first field to be used
path1: string; path1?: string;
// path2 if the second field to be used, optional // path2 if the second field to be used, optional
path2?: string; path2?: string;
// options of the payto uri // params of the payto uri
options: { params: {
"receiver-name"?: string; "receiver-name"?: string;
sender?: string; sender?: string;
message?: string; message?: string;
@ -52,13 +52,6 @@ type Entity = {
instruction?: string; instruction?: string;
[name: string]: string | undefined; [name: string]: string | undefined;
}; };
auth: {
type: "unset" | "basic" | "none";
url?: string;
username?: string;
password?: string;
repeat?: string;
};
}; };
function isEthereumAddress(address: string) { function isEthereumAddress(address: string) {
@ -171,14 +164,10 @@ const targets = [
"bitcoin", "bitcoin",
"ethereum", "ethereum",
]; ];
const accountAuthType = ["none", "basic"];
const noTargetValue = targets[0]; const noTargetValue = targets[0];
const defaultTarget: Partial<Entity> = { const defaultTarget: Entity = {
target: noTargetValue, target: noTargetValue,
options: {}, params: {},
auth: {
type: "unset" as const,
},
}; };
export function InputPaytoForm<T>({ export function InputPaytoForm<T>({
@ -187,110 +176,91 @@ export function InputPaytoForm<T>({
label, label,
tooltip, tooltip,
}: Props<keyof T>): VNode { }: Props<keyof T>): VNode {
const { value: paytos, onChange, required } = useField<T>(name); const { value: initialValueStr, onChange } = useField<T>(name);
const [value, valueHandler] = useState<Partial<Entity>>(defaultTarget); const initialPayto = parsePaytoUri(initialValueStr ?? "")
const paths = !initialPayto ? [] : initialPayto.targetPath.split("/")
let payToPath; const initialPath1 = paths.length >= 1 ? paths[0] : undefined;
if (value.target === "iban" && value.path1) { const initialPath2 = paths.length >= 2 ? paths[1] : undefined;
payToPath = `/${value.path1.toUpperCase()}`; const initial: Entity = initialPayto === undefined ? defaultTarget : {
} else if (value.path1) { target: initialPayto.targetType,
if (value.path2) { params: initialPayto.params,
payToPath = `/${value.path1}/${value.path2}`; path1: initialPath1,
} else { path2: initialPath2,
payToPath = `/${value.path1}`;
}
} }
const [value, setValue] = useState<Partial<Entity>>(initial)
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const ops = value.options ?? {};
const url = tryUrl(`payto://${value.target}${payToPath}`);
if (url) {
Object.keys(ops).forEach((opt_key) => {
const opt_value = ops[opt_key];
if (opt_value) url.searchParams.set(opt_key, opt_value);
});
}
const paytoURL = !url ? "" : url.href;
const errors: FormErrors<Entity> = { const errors: FormErrors<Entity> = {
target: target:
value.target === noTargetValue && !paytos.length value.target === noTargetValue
? i18n.str`required` ? i18n.str`required`
: undefined, : undefined,
path1: !value.path1 path1: !value.path1
? i18n.str`required` ? i18n.str`required`
: value.target === "iban" : value.target === "iban"
? validateIBAN(value.path1, i18n) ? validateIBAN(value.path1, i18n)
: value.target === "bitcoin" : value.target === "bitcoin"
? validateBitcoin(value.path1, i18n) ? validateBitcoin(value.path1, i18n)
: value.target === "ethereum" : value.target === "ethereum"
? validateEthereum(value.path1, i18n) ? validateEthereum(value.path1, i18n)
: undefined, : undefined,
path2: path2:
value.target === "x-taler-bank" value.target === "x-taler-bank"
? !value.path2 ? !value.path2
? i18n.str`required` ? i18n.str`required`
: undefined : undefined
: undefined, : undefined,
options: undefinedIfEmpty({ params: undefinedIfEmpty({
"receiver-name": !value.options?.["receiver-name"] "receiver-name": !value.params?.["receiver-name"]
? i18n.str`required` ? i18n.str`required`
: undefined, : undefined,
}), }),
auth: !value.auth
? undefined
: undefinedIfEmpty({
username:
value.auth.type === "basic" && !value.auth.username
? i18n.str`required`
: undefined,
password:
value.auth.type === "basic" && !value.auth.password
? i18n.str`required`
: undefined,
repeat:
value.auth.type === "basic" && !value.auth.repeat
? i18n.str`required`
: value.auth.repeat !== value.auth.password
? i18n.str`is not the same`
: undefined,
}),
}; };
const hasErrors = Object.keys(errors).some( const hasErrors = Object.keys(errors).some(
(k) => (errors as any)[k] !== undefined, (k) => (errors as any)[k] !== undefined,
); );
const str = hasErrors || !value.target ? undefined : stringifyPaytoUri({
targetType: value.target,
targetPath: value.path2 ? `${value.path1}/${value.path2}` : (value.path1 ?? ""),
params: value.params ?? {} as any,
isKnown: false,
})
useEffect(() => {
onChange(str as any)
}, [str])
const submit = useCallback((): void => { // const submit = useCallback((): void => {
const accounts: MerchantBackend.Instances.MerchantBankAccount[] = paytos; // // const accounts: MerchantBackend.BankAccounts.AccountAddDetails[] = paytos;
const alreadyExists = // // const alreadyExists =
accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1; // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1;
if (!alreadyExists) { // // if (!alreadyExists) {
const newValue: MerchantBackend.Instances.MerchantBankAccount = { // const newValue: MerchantBackend.BankAccounts.AccountAddDetails = {
payto_uri: paytoURL, // payto_uri: paytoURL,
}; // };
if (value.auth) { // if (value.auth) {
if (value.auth.url) { // if (value.auth.url) {
newValue.credit_facade_url = value.auth.url; // newValue.credit_facade_url = value.auth.url;
} // }
if (value.auth.type === "none") { // if (value.auth.type === "none") {
newValue.credit_facade_credentials = { // newValue.credit_facade_credentials = {
type: "none", // type: "none",
}; // };
} // }
if (value.auth.type === "basic") { // if (value.auth.type === "basic") {
newValue.credit_facade_credentials = { // newValue.credit_facade_credentials = {
type: "basic", // type: "basic",
username: value.auth.username ?? "", // username: value.auth.username ?? "",
password: value.auth.password ?? "", // password: value.auth.password ?? "",
}; // };
} // }
} // }
onChange([newValue, ...accounts] as any); // onChange(newValue as any);
} // // }
valueHandler(defaultTarget); // // valueHandler(defaultTarget);
}, [value]); // }, [value]);
//FIXME: translating plural singular //FIXME: translating plural singular
return ( return (
@ -299,11 +269,11 @@ export function InputPaytoForm<T>({
name="tax" name="tax"
errors={errors} errors={errors}
object={value} object={value}
valueHandler={valueHandler} valueHandler={setValue}
> >
<InputSelector<Entity> <InputSelector<Entity>
name="target" name="target"
label={i18n.str`Target type`} label={i18n.str`Account type`}
tooltip={i18n.str`Method to use for wire transfer`} tooltip={i18n.str`Method to use for wire transfer`}
values={targets} values={targets}
toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)} toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)}
@ -400,150 +370,15 @@ export function InputPaytoForm<T>({
{value.target !== noTargetValue && ( {value.target !== noTargetValue && (
<Fragment> <Fragment>
<Input <Input
name="options.receiver-name" name="params.receiver-name"
label={i18n.str`Name`} label={i18n.str`Name`}
tooltip={i18n.str`Bank account owner's name.`} tooltip={i18n.str`Bank account owner's name.`}
/> />
<InputWithAddon
name="auth.url"
label={i18n.str`Account info URL`}
help="https://bank.com"
expand
tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`}
/>
<InputSelector
name="auth.type"
label={i18n.str`Auth type`}
tooltip={i18n.str`Choose the authentication type for the account info URL`}
values={accountAuthType}
toStr={(str) => {
// if (str === "unset") {
// return "Without change";
// }
if (str === "none") return "Without authentication";
return "Username and password";
}}
/>
{value.auth?.type === "basic" ? (
<Fragment>
<Input
name="auth.username"
label={i18n.str`Username`}
tooltip={i18n.str`Username to access the account information.`}
/>
<Input
name="auth.password"
inputType="password"
label={i18n.str`Password`}
tooltip={i18n.str`Password to access the account information.`}
/>
<Input
name="auth.repeat"
inputType="password"
label={i18n.str`Repeat password`}
/>
</Fragment>
) : undefined}
{/* <InputWithAddon
name="options.credit_credentials"
label={i18n.str`Account info`}
inputType={showKey ? "text" : "password"}
help="From where the merchant can download information about incoming wire transfers to this account"
expand
tooltip={i18n.str`Useful to validate the purchase`}
fromStr={(v) => v.toUpperCase()}
addonAfter={
<span class="icon">
{showKey ? (
<i class="mdi mdi-eye" />
) : (
<i class="mdi mdi-eye-off" />
)}
</span>
}
side={
<span style={{ display: "flex" }}>
<button
data-tooltip={
showKey
? i18n.str`show secret key`
: i18n.str`hide secret key`
}
class="button is-info mr-3"
onClick={(e) => {
setShowKey(!showKey);
}}
>
{showKey ? (
<i18n.Translate>hide</i18n.Translate>
) : (
<i18n.Translate>show</i18n.Translate>
)}
</button>
</span>
}
/> */}
</Fragment> </Fragment>
)} )}
{/**
* Show the values in the list
*/}
<div class="field is-horizontal">
<div class="field-label is-normal" />
<div class="field-body" style={{ display: "block" }}>
{paytos.map(
(v: MerchantBackend.Instances.MerchantBankAccount, i: number) => (
<div
key={i}
class="tags has-addons mt-3 mb-0 mr-3"
style={{ flexWrap: "nowrap" }}
>
<span
class="tag is-medium is-info mb-0"
style={{ maxWidth: "90%" }}
>
{v.payto_uri}
</span>
<a
class="tag is-medium is-danger is-delete mb-0"
onClick={() => {
onChange(paytos.filter((f: any) => f !== v) as any);
}}
/>
</div>
),
)}
{!paytos.length && i18n.str`No accounts yet.`}
{required && (
<span class="icon has-text-danger is-right">
<i class="mdi mdi-alert" />
</span>
)}
</div>
</div>
{value.target !== noTargetValue && (
<div class="buttons is-right mt-5">
<button
class="button is-info"
data-tooltip={i18n.str`add tax to the tax list`}
disabled={hasErrors}
onClick={submit}
>
<i18n.Translate>Add</i18n.Translate>
</button>
</div>
)}
</FormProvider> </FormProvider>
</InputGroup> </InputGroup>
); );
} }
function tryUrl(s: string): URL | undefined {
try {
return new URL(s);
} catch (e) {
return undefined;
}
}

View File

@ -22,32 +22,41 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import emptyImage from "../../assets/empty.png"; import emptyImage from "../../assets/empty.png";
import { MerchantBackend, WithId } from "../../declaration.js";
import { FormErrors, FormProvider } from "./FormProvider.js"; import { FormErrors, FormProvider } from "./FormProvider.js";
import { InputWithAddon } from "./InputWithAddon.js"; import { InputWithAddon } from "./InputWithAddon.js";
import { TranslatedString } from "@gnu-taler/taler-util";
type Entity = MerchantBackend.Products.ProductDetail & WithId; type Entity = {
id: string,
description: string;
image?: string;
extra?: string;
};
export interface Props { export interface Props<T extends Entity> {
selected?: Entity; selected?: T;
onChange: (p?: Entity) => void; onChange: (p?: T) => void;
products: (MerchantBackend.Products.ProductDetail & WithId)[]; label: TranslatedString;
list: T[];
withImage?: boolean;
} }
interface ProductSearch { interface Search {
name: string; name: string;
} }
export function InputSearchProduct({ export function InputSearchOnList<T extends Entity>({
selected, selected,
onChange, onChange,
products, label,
}: Props): VNode { list,
const [prodForm, setProdName] = useState<Partial<ProductSearch>>({ withImage,
}: Props<T>): VNode {
const [nameForm, setNameForm] = useState<Partial<Search>>({
name: "", name: "",
}); });
const errors: FormErrors<ProductSearch> = { const errors: FormErrors<Search> = {
name: undefined, name: undefined,
}; };
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -55,15 +64,17 @@ export function InputSearchProduct({
if (selected) { if (selected) {
return ( return (
<article class="media"> <article class="media">
<figure class="media-left"> {withImage &&
<p class="image is-128x128"> <figure class="media-left">
<img src={selected.image ? selected.image : emptyImage} /> <p class="image is-128x128">
</p> <img src={selected.image ? selected.image : emptyImage} />
</figure> </p>
</figure>
}
<div class="media-content"> <div class="media-content">
<div class="content"> <div class="content">
<p class="media-meta"> <p class="media-meta">
<i18n.Translate>Product id</i18n.Translate>: <b>{selected.id}</b> <i18n.Translate>ID</i18n.Translate>: <b>{selected.id}</b>
</p> </p>
<p> <p>
<i18n.Translate>Description</i18n.Translate>:{" "} <i18n.Translate>Description</i18n.Translate>:{" "}
@ -84,15 +95,15 @@ export function InputSearchProduct({
} }
return ( return (
<FormProvider<ProductSearch> <FormProvider<Search>
errors={errors} errors={errors}
object={prodForm} object={nameForm}
valueHandler={setProdName} valueHandler={setNameForm}
> >
<InputWithAddon<ProductSearch> <InputWithAddon<Search>
name="name" name="name"
label={i18n.str`Product`} label={label}
tooltip={i18n.str`search products by it's description or id`} tooltip={i18n.str`enter description or id`}
addonAfter={ addonAfter={
<span class="icon"> <span class="icon">
<i class="mdi mdi-magnify" /> <i class="mdi mdi-magnify" />
@ -100,13 +111,14 @@ export function InputSearchProduct({
} }
> >
<div> <div>
<ProductList <DropdownList
name={prodForm.name} name={nameForm.name}
list={products} list={list}
onSelect={(p) => { onSelect={(p) => {
setProdName({ name: "" }); setNameForm({ name: "" });
onChange(p); onChange(p);
}} }}
withImage={!!withImage}
/> />
</div> </div>
</InputWithAddon> </InputWithAddon>
@ -114,13 +126,14 @@ export function InputSearchProduct({
); );
} }
interface ProductListProps { interface DropdownListProps<T extends Entity> {
name?: string; name?: string;
onSelect: (p: MerchantBackend.Products.ProductDetail & WithId) => void; onSelect: (p: T) => void;
list: (MerchantBackend.Products.ProductDetail & WithId)[]; list: T[];
withImage: boolean;
} }
function ProductList({ name, onSelect, list }: ProductListProps) { function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
if (!name) { if (!name) {
/* FIXME /* FIXME
@ -149,7 +162,7 @@ function ProductList({ name, onSelect, list }: ProductListProps) {
{!filtered.length ? ( {!filtered.length ? (
<div class="dropdown-item"> <div class="dropdown-item">
<i18n.Translate> <i18n.Translate>
no products found with that description no match found with that description or id
</i18n.Translate> </i18n.Translate>
</div> </div>
) : ( ) : (
@ -161,18 +174,20 @@ function ProductList({ name, onSelect, list }: ProductListProps) {
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
<article class="media"> <article class="media">
<div class="media-left"> {withImage &&
<div class="image" style={{ minWidth: 64 }}> <div class="media-left">
<img <div class="image" style={{ minWidth: 64 }}>
src={p.image ? p.image : emptyImage} <img
style={{ width: 64, height: 64 }} src={p.image ? p.image : emptyImage}
/> style={{ width: 64, height: 64 }}
/>
</div>
</div> </div>
</div> }
<div class="media-content"> <div class="media-content">
<div class="content"> <div class="content">
<p> <p>
<strong>{p.id}</strong> <small>{p.price}</small> <strong>{p.id}</strong> {p.extra !== undefined ? <small>{p.extra}</small> : undefined}
<br /> <br />
{p.description} {p.description}
</p> </p>

View File

@ -56,7 +56,7 @@ export function InputToggle<T>({
return ( return (
<div class="field is-horizontal"> <div class="field is-horizontal">
<div class="field-label is-normal"> <div class="field-label is-normal">
<label class="label" style={{ width: 200 }}> <label class="label" >
{label} {label}
{tooltip && ( {tooltip && (
<span class="icon has-tooltip-right" data-tooltip={tooltip}> <span class="icon has-tooltip-right" data-tooltip={tooltip}>
@ -65,7 +65,7 @@ export function InputToggle<T>({
)} )}
</label> </label>
</div> </div>
<div class="field-body is-flex-grow-1"> <div class="field-body is-flex-grow-3">
<div class="field"> <div class="field">
<p class={expand ? "control is-expanded" : "control"}> <p class={expand ? "control is-expanded" : "control"}>
<label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}> <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}>

View File

@ -24,14 +24,13 @@ import { Fragment, h, VNode } from "preact";
import { useBackendContext } from "../../context/backend.js"; import { useBackendContext } from "../../context/backend.js";
import { Entity } from "../../paths/admin/create/CreatePage.js"; import { Entity } from "../../paths/admin/create/CreatePage.js";
import { Input } from "../form/Input.js"; import { Input } from "../form/Input.js";
import { InputCurrency } from "../form/InputCurrency.js";
import { InputDuration } from "../form/InputDuration.js"; import { InputDuration } from "../form/InputDuration.js";
import { InputGroup } from "../form/InputGroup.js"; import { InputGroup } from "../form/InputGroup.js";
import { InputImage } from "../form/InputImage.js"; import { InputImage } from "../form/InputImage.js";
import { InputLocation } from "../form/InputLocation.js"; import { InputLocation } from "../form/InputLocation.js";
import { InputPaytoForm } from "../form/InputPaytoForm.js";
import { InputWithAddon } from "../form/InputWithAddon.js";
import { InputSelector } from "../form/InputSelector.js"; import { InputSelector } from "../form/InputSelector.js";
import { InputToggle } from "../form/InputToggle.js";
import { InputWithAddon } from "../form/InputWithAddon.js";
export function DefaultInstanceFormFields({ export function DefaultInstanceFormFields({
readonlyId, readonlyId,
@ -85,28 +84,10 @@ export function DefaultInstanceFormFields({
tooltip={i18n.str`Logo image.`} tooltip={i18n.str`Logo image.`}
/> />
<InputPaytoForm<Entity> <InputToggle<Entity>
name="accounts" name="use_stefan"
label={i18n.str`Bank account`} label={i18n.str`Pay transaction fee`}
tooltip={i18n.str`URI specifying bank account for crediting revenue.`} tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`}
/>
<InputCurrency<Entity>
name="default_max_deposit_fee"
label={i18n.str`Default max deposit fee`}
tooltip={i18n.str`Maximum deposit fees this merchant is willing to pay per order by default.`}
/>
<InputCurrency<Entity>
name="default_max_wire_fee"
label={i18n.str`Default max wire fee`}
tooltip={i18n.str`Maximum wire fees this merchant is willing to pay per wire transfer by default.`}
/>
<Input<Entity>
name="default_wire_fee_amortization"
label={i18n.str`Default wire fee amortization`}
tooltip={i18n.str`Number of orders excess wire transfer fees will be divided by to compute per order surcharge.`}
/> />
<InputGroup <InputGroup

View File

@ -25,6 +25,7 @@ import { useBackendContext } from "../../context/backend.js";
import { useConfigContext } from "../../context/config.js"; import { useConfigContext } from "../../context/config.js";
import { useInstanceKYCDetails } from "../../hooks/instance.js"; import { useInstanceKYCDetails } from "../../hooks/instance.js";
import { LangSelector } from "./LangSelector.js"; import { LangSelector } from "./LangSelector.js";
import { useCredentialsChecker } from "../../hooks/backend.js";
const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
@ -36,6 +37,7 @@ interface Props {
instance: string; instance: string;
admin?: boolean; admin?: boolean;
mimic?: boolean; mimic?: boolean;
isPasswordOk: boolean;
} }
export function Sidebar({ export function Sidebar({
@ -45,6 +47,7 @@ export function Sidebar({
onLogout, onLogout,
admin, admin,
mimic, mimic,
isPasswordOk
}: Props): VNode { }: Props): VNode {
const config = useConfigContext(); const config = useConfigContext();
const backend = useBackendContext(); const backend = useBackendContext();
@ -53,7 +56,7 @@ export function Sidebar({
const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; const needKYC = kycStatus.ok && kycStatus.data.type === "redirect";
return ( return (
<aside class="aside is-placed-left is-expanded"> <aside class="aside is-placed-left is-expanded" style={{ overflowY: "scroll" }}>
{mobile && ( {mobile && (
<div <div
class="footer" class="footer"
@ -78,10 +81,10 @@ export function Sidebar({
</div> </div>
</div> </div>
<div class="menu is-menu-main"> <div class="menu is-menu-main">
{instance ? ( {isPasswordOk && instance ? (
<Fragment> <Fragment>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
<a href={"/orders"} class="has-icon"> <a href={"/orders"} class="has-icon">
<span class="icon"> <span class="icon">
<i class="mdi mdi-cash-register" /> <i class="mdi mdi-cash-register" />
@ -104,7 +107,7 @@ export function Sidebar({
<li> <li>
<a href={"/transfers"} class="has-icon"> <a href={"/transfers"} class="has-icon">
<span class="icon"> <span class="icon">
<i class="mdi mdi-bank" /> <i class="mdi mdi-arrow-left-right" />
</span> </span>
<span class="menu-item-label"> <span class="menu-item-label">
<i18n.Translate>Transfers</i18n.Translate> <i18n.Translate>Transfers</i18n.Translate>
@ -137,12 +140,22 @@ export function Sidebar({
</p> </p>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
<a href={"/update"} class="has-icon"> <a href={"/bank"} class="has-icon">
<span class="icon"> <span class="icon">
<i class="mdi mdi-square-edit-outline" /> <i class="mdi mdi-bank" />
</span> </span>
<span class="menu-item-label"> <span class="menu-item-label">
<i18n.Translate>Account</i18n.Translate> <i18n.Translate>Bank account</i18n.Translate>
</span>
</a>
</li>
<li>
<a href={"/validators"} class="has-icon">
<span class="icon">
<i class="mdi mdi-lock" />
</span>
<span class="menu-item-label">
<i18n.Translate>Validators</i18n.Translate>
</span> </span>
</a> </a>
</li> </li>
@ -164,6 +177,26 @@ export function Sidebar({
</span> </span>
</a> </a>
</li> </li>
<li>
<a href={"/server"} class="has-icon">
<span class="icon">
<i class="mdi mdi-square-edit-outline" />
</span>
<span class="menu-item-label">
<i18n.Translate>Server</i18n.Translate>
</span>
</a>
</li>
<li>
<a href={"/token"} class="has-icon">
<span class="icon">
<i class="mdi mdi-security" />
</span>
<span class="menu-item-label">
<i18n.Translate>Access token</i18n.Translate>
</span>
</a>
</li>
</ul> </ul>
</Fragment> </Fragment>
) : undefined} ) : undefined}
@ -174,12 +207,12 @@ export function Sidebar({
<li> <li>
<a class="has-icon is-state-info is-hoverable" <a class="has-icon is-state-info is-hoverable"
onClick={(): void => onShowSettings()} onClick={(): void => onShowSettings()}
> >
<span class="icon"> <span class="icon">
<i class="mdi mdi-newspaper" /> <i class="mdi mdi-newspaper" />
</span> </span>
<span class="menu-item-label"> <span class="menu-item-label">
<i18n.Translate>Settings</i18n.Translate> <i18n.Translate>Interface</i18n.Translate>
</span> </span>
</a> </a>
</li> </li>
@ -211,7 +244,7 @@ export function Sidebar({
</span> </span>
</div> </div>
</li> </li>
{admin && !mimic && ( {isPasswordOk && admin && !mimic && (
<Fragment> <Fragment>
<p class="menu-label"> <p class="menu-label">
<i18n.Translate>Instances</i18n.Translate> <i18n.Translate>Instances</i18n.Translate>
@ -238,19 +271,21 @@ export function Sidebar({
</li> </li>
</Fragment> </Fragment>
)} )}
<li> {isPasswordOk &&
<a <li>
class="has-icon is-state-info is-hoverable" <a
onClick={(): void => onLogout()} class="has-icon is-state-info is-hoverable"
> onClick={(): void => onLogout()}
<span class="icon"> >
<i class="mdi mdi-logout default" /> <span class="icon">
</span> <i class="mdi mdi-logout default" />
<span class="menu-item-label"> </span>
<i18n.Translate>Log out</i18n.Translate> <span class="menu-item-label">
</span> <i18n.Translate>Log out</i18n.Translate>
</a> </span>
</li> </a>
</li>
}
</ul> </ul>
</div> </div>
</aside> </aside>

View File

@ -24,7 +24,7 @@ import { Sidebar } from "./SideBar.js";
function getInstanceTitle(path: string, id: string): string { function getInstanceTitle(path: string, id: string): string {
switch (path) { switch (path) {
case InstancePaths.update: case InstancePaths.server:
return `${id}: Settings`; return `${id}: Settings`;
case InstancePaths.order_list: case InstancePaths.order_list:
return `${id}: Orders`; return `${id}: Orders`;
@ -50,6 +50,12 @@ function getInstanceTitle(path: string, id: string): string {
return `${id}: New webhook`; return `${id}: New webhook`;
case InstancePaths.webhooks_update: case InstancePaths.webhooks_update:
return `${id}: Update webhook`; return `${id}: Update webhook`;
case InstancePaths.validators_list:
return `${id}: Validators`;
case InstancePaths.validators_new:
return `${id}: New validator`;
case InstancePaths.validators_update:
return `${id}: Update validators`;
case InstancePaths.templates_new: case InstancePaths.templates_new:
return `${id}: New template`; return `${id}: New template`;
case InstancePaths.templates_update: case InstancePaths.templates_update:
@ -58,6 +64,10 @@ function getInstanceTitle(path: string, id: string): string {
return `${id}: Templates`; return `${id}: Templates`;
case InstancePaths.templates_use: case InstancePaths.templates_use:
return `${id}: Use template`; return `${id}: Use template`;
case InstancePaths.settings:
return `${id}: Interface`;
case InstancePaths.settings:
return `${id}: Interface`;
default: default:
return ""; return "";
} }
@ -77,6 +87,7 @@ interface MenuProps {
onLogout?: () => void; onLogout?: () => void;
onShowSettings: () => void; onShowSettings: () => void;
setInstanceName: (s: string) => void; setInstanceName: (s: string) => void;
isPasswordOk: boolean;
} }
function WithTitle({ function WithTitle({
@ -100,14 +111,15 @@ export function Menu({
path, path,
admin, admin,
setInstanceName, setInstanceName,
isPasswordOk
}: MenuProps): VNode { }: MenuProps): VNode {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const titleWithSubtitle = title const titleWithSubtitle = title
? title ? title
: !admin : !admin
? getInstanceTitle(path, instance) ? getInstanceTitle(path, instance)
: getAdminTitle(path, instance); : getAdminTitle(path, instance);
const adminInstance = instance === "default"; const adminInstance = instance === "default";
const mimic = admin && !adminInstance; const mimic = admin && !adminInstance;
return ( return (
@ -129,14 +141,15 @@ export function Menu({
mimic={mimic} mimic={mimic}
instance={instance} instance={instance}
mobile={mobileOpen} mobile={mobileOpen}
isPasswordOk={isPasswordOk}
/> />
)} )}
{mimic && ( {mimic && (
<nav class="level" style={{ <nav class="level" style={{
zIndex: 100, zIndex: 100,
position:"fixed", position: "fixed",
width:"50%", width: "50%",
marginLeft: "20%" marginLeft: "20%"
}}> }}>
<div class="level-item has-text-centered has-background-warning"> <div class="level-item has-text-centered has-background-warning">
@ -161,8 +174,9 @@ export function Menu({
interface NotYetReadyAppMenuProps { interface NotYetReadyAppMenuProps {
title: string; title: string;
onLogout?: () => void;
onShowSettings: () => void; onShowSettings: () => void;
onLogout?: () => void;
isPasswordOk: boolean;
} }
interface NotifProps { interface NotifProps {
@ -181,8 +195,8 @@ export function NotificationCard({
n.type === "ERROR" n.type === "ERROR"
? "message is-danger" ? "message is-danger"
: n.type === "WARN" : n.type === "WARN"
? "message is-warning" ? "message is-warning"
: "message is-info" : "message is-info"
} }
> >
<div class="message-header"> <div class="message-header">
@ -201,10 +215,36 @@ export function NotificationCard({
); );
} }
interface NotConnectedAppMenuProps {
title: string;
}
export function NotConnectedAppMenu({
title,
}: NotConnectedAppMenuProps): VNode {
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
document.title = `Taler Backoffice: ${title}`;
}, [title]);
return (
<div
class={mobileOpen ? "has-aside-mobile-expanded" : ""}
onClick={() => setMobileOpen(false)}
>
<NavigationBar
onMobileMenu={() => setMobileOpen(!mobileOpen)}
title={title}
/>
</div>
);
}
export function NotYetReadyAppMenu({ export function NotYetReadyAppMenu({
onLogout, onLogout,
onShowSettings, onShowSettings,
title, title,
isPasswordOk
}: NotYetReadyAppMenuProps): VNode { }: NotYetReadyAppMenuProps): VNode {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
@ -222,7 +262,7 @@ export function NotYetReadyAppMenu({
title={title} title={title}
/> />
{onLogout && ( {onLogout && (
<Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} /> <Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} isPasswordOk={isPasswordOk} />
)} )}
</div> </div>
); );

View File

@ -20,7 +20,7 @@ import { MerchantBackend, WithId } from "../../declaration.js";
import { ProductMap } from "../../paths/instance/orders/create/CreatePage.js"; import { ProductMap } from "../../paths/instance/orders/create/CreatePage.js";
import { FormErrors, FormProvider } from "../form/FormProvider.js"; import { FormErrors, FormProvider } from "../form/FormProvider.js";
import { InputNumber } from "../form/InputNumber.js"; import { InputNumber } from "../form/InputNumber.js";
import { InputSearchProduct } from "../form/InputSearchProduct.js"; import { InputSearchOnList } from "../form/InputSearchOnList.js";
type Form = { type Form = {
product: MerchantBackend.Products.ProductDetail & WithId; product: MerchantBackend.Products.ProductDetail & WithId;
@ -95,10 +95,12 @@ export function InventoryProductForm({
return ( return (
<FormProvider<Form> errors={errors} object={state} valueHandler={setState}> <FormProvider<Form> errors={errors} object={state} valueHandler={setState}>
<InputSearchProduct <InputSearchOnList
label={i18n.str`Search product`}
selected={state.product} selected={state.product}
onChange={(p) => setState((v) => ({ ...v, product: p }))} onChange={(p) => setState((v) => ({ ...v, product: p }))}
products={inventory} list={inventory}
withImage
/> />
{state.product && ( {state.product && (
<div class="columns mt-5"> <div class="columns mt-5">

View File

@ -58,12 +58,12 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
!initial || initial.total_stock === -1 !initial || initial.total_stock === -1
? undefined ? undefined
: { : {
current: initial.total_stock || 0, current: initial.total_stock || 0,
lost: initial.total_lost || 0, lost: initial.total_lost || 0,
sold: initial.total_sold || 0, sold: initial.total_sold || 0,
address: initial.address, address: initial.address,
nextRestock: initial.next_restock, nextRestock: initial.next_restock,
}, },
}); });
let errors: FormErrors<Entity> = {}; let errors: FormErrors<Entity> = {};
@ -148,15 +148,17 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
name="minimum_age" name="minimum_age"
label={i18n.str`Age restricted`} label={i18n.str`Age restricted`}
tooltip={i18n.str`is this product restricted for customer below certain age?`} tooltip={i18n.str`is this product restricted for customer below certain age?`}
help={i18n.str`can be overridden by the order configuration`}
/> />
<Input<Entity> <Input<Entity>
name="unit" name="unit"
label={i18n.str`Unit`} label={i18n.str`Unit name`}
tooltip={i18n.str`unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers`} tooltip={i18n.str`unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers`}
help={i18n.str`exajmple: kg, items or liters`}
/> />
<InputCurrency<Entity> <InputCurrency<Entity>
name="price" name="price"
label={i18n.str`Price`} label={i18n.str`Price per unit`}
tooltip={i18n.str`sale price for customers, including taxes, for above units of the product`} tooltip={i18n.str`sale price for customers, including taxes, for above units of the product`}
/> />
<InputStock <InputStock

View File

@ -28,8 +28,8 @@ interface BackendContextType {
token?: string; token?: string;
triedToLog: boolean; triedToLog: boolean;
resetBackend: () => void; resetBackend: () => void;
clearAllTokens: () => void; // clearAllTokens: () => void;
addTokenCleaner: (c: () => void) => void; // addTokenCleaner: (c: () => void) => void;
updateLoginStatus: (url: string, token?: string) => void; updateLoginStatus: (url: string, token?: string) => void;
updateToken: (token?: string) => void; updateToken: (token?: string) => void;
} }
@ -39,8 +39,8 @@ const BackendContext = createContext<BackendContextType>({
token: undefined, token: undefined,
triedToLog: false, triedToLog: false,
resetBackend: () => null, resetBackend: () => null,
clearAllTokens: () => null, // clearAllTokens: () => null,
addTokenCleaner: () => null, // addTokenCleaner: () => null,
updateLoginStatus: () => null, updateLoginStatus: () => null,
updateToken: () => null, updateToken: () => null,
}); });
@ -56,30 +56,30 @@ function useBackendContextState(
_updateToken(t); _updateToken(t);
}; };
const tokenCleaner = useCallback(() => { // const tokenCleaner = useCallback(() => {
updateToken(undefined); // updateToken(undefined);
}, []); // }, []);
const [cleaners, setCleaners] = useState([tokenCleaner]); // const [cleaners, setCleaners] = useState([tokenCleaner]);
const addTokenCleaner = (c: () => void) => setCleaners((cs) => [...cs, c]); // const addTokenCleaner = (c: () => void) => setCleaners((cs) => [...cs, c]);
const addTokenCleanerMemo = useCallback( // const addTokenCleanerMemo = useCallback(
(c: () => void) => { // (c: () => void) => {
addTokenCleaner(c); // addTokenCleaner(c);
}, // },
[tokenCleaner], // [tokenCleaner],
); // );
const clearAllTokens = () => { // const clearAllTokens = () => {
cleaners.forEach((c) => c()); // cleaners.forEach((c) => c());
for (let i = 0; i < localStorage.length; i++) { // for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i); // const k = localStorage.key(i);
if (k && /^backend-token/.test(k)) localStorage.removeItem(k); // if (k && /^backend-token/.test(k)) localStorage.removeItem(k);
} // }
resetBackend(); // resetBackend();
}; // };
const updateLoginStatus = (url: string, token?: string) => { const updateLoginStatus = (url: string, token?: string) => {
changeBackend(url); changeBackend(url);
if (token) updateToken(token); updateToken(token);
}; };
return { return {
@ -88,9 +88,9 @@ function useBackendContextState(
triedToLog, triedToLog,
updateLoginStatus, updateLoginStatus,
resetBackend, resetBackend,
clearAllTokens, // clearAllTokens,
updateToken, updateToken,
addTokenCleaner: addTokenCleanerMemo, // addTokenCleaner: addTokenCleanerMemo,
}; };
} }

View File

@ -25,6 +25,8 @@ type EddsaSignature = string;
type WireTransferIdentifierRawP = string; type WireTransferIdentifierRawP = string;
type RelativeTime = Duration; type RelativeTime = Duration;
type ImageDataUrl = string; type ImageDataUrl = string;
type MerchantUserType = "business" | "individual";
export interface WithId { export interface WithId {
id: string; id: string;
@ -312,46 +314,8 @@ export namespace MerchantBackend {
// header. // header.
token?: string; token?: string;
} }
type FacadeCredentials = NoFacadeCredentials | BasicAuthFacadeCredentials;
interface NoFacadeCredentials {
type: "none";
}
interface BasicAuthFacadeCredentials {
type: "basic";
// Username to use to authenticate
username: string;
// Password to use to authenticate
password: string;
}
interface MerchantBankAccount {
// The payto:// URI where the wallet will send coins.
payto_uri: string;
// Optional base URL for a facade where the
// merchant backend can see incoming wire
// transfers to reconcile its accounting
// with that of the exchange. Used by
// taler-merchant-wirewatch.
credit_facade_url?: string;
// Credentials for accessing the credit facade.
credit_facade_credentials?: FacadeCredentials;
}
//POST /private/instances //POST /private/instances
interface InstanceConfigurationMessage { interface InstanceConfigurationMessage {
// Bank accounts of the merchant. A merchant may have
// multiple accounts, thus this is an array. Note that by
// removing accounts from this list the respective account is set to
// inactive and thus unavailable for new contracts, but preserved
// in the database as existing offers and contracts may still refer
// to it.
accounts: MerchantBankAccount[];
// Name of the merchant instance to create (will become $INSTANCE). // Name of the merchant instance to create (will become $INSTANCE).
id: string; id: string;
@ -361,12 +325,16 @@ export namespace MerchantBackend {
// Type of the user (business or individual). // Type of the user (business or individual).
// Defaults to 'business'. Should become mandatory field // Defaults to 'business'. Should become mandatory field
// in the future, left as optional for API compatibility for now. // in the future, left as optional for API compatibility for now.
user_type?: string; user_type?: MerchantUserType;
email: string; // Merchant email for customer contact.
website: string; email?: string;
// An optional base64-encoded logo image
logo: ImageDataUrl; // Merchant public website.
website?: string;
// Merchant logo.
logo?: ImageDataUrl;
// "Authentication" header required to authorize management access the instance. // "Authentication" header required to authorize management access the instance.
// Optional, if not given authentication will be disabled for // Optional, if not given authentication will be disabled for
@ -381,17 +349,10 @@ export namespace MerchantBackend {
// (to be put into contracts). // (to be put into contracts).
jurisdiction: Location; jurisdiction: Location;
// Maximum wire fee this instance is willing to pay. // Use STEFAN curves to determine default fees?
// Can be overridden by the frontend on a per-order basis. // If false, no fees are allowed by default.
default_max_wire_fee: Amount; // Can always be overridden by the frontend on a per-order basis.
use_stefan: boolean;
// Default factor for wire fee amortization calculations.
// Can be overridden by the frontend on a per-order basis.
default_wire_fee_amortization: Integer;
// Maximum deposit fee (sum over all coins) this instance is willing to pay.
// Can be overridden by the frontend on a per-order basis.
default_max_deposit_fee: Amount;
// If the frontend does NOT specify an execution date, how long should // If the frontend does NOT specify an execution date, how long should
// we tell the exchange to wait to aggregate transactions before // we tell the exchange to wait to aggregate transactions before
@ -406,11 +367,6 @@ export namespace MerchantBackend {
// PATCH /private/instances/$INSTANCE // PATCH /private/instances/$INSTANCE
interface InstanceReconfigurationMessage { interface InstanceReconfigurationMessage {
// Bank accounts of the merchant. A merchant may have
// multiple accounts, thus this is an array. Note that removing
// URIs from this list deactivates the specified accounts
// (they will no longer be used for future contracts).
accounts: MerchantBankAccount[];
// Merchant name corresponding to this instance. // Merchant name corresponding to this instance.
name: string; name: string;
@ -418,7 +374,16 @@ export namespace MerchantBackend {
// Type of the user (business or individual). // Type of the user (business or individual).
// Defaults to 'business'. Should become mandatory field // Defaults to 'business'. Should become mandatory field
// in the future, left as optional for API compatibility for now. // in the future, left as optional for API compatibility for now.
user_type?: string; user_type?: MerchantUserType;
// Merchant email for customer contact.
email?: string;
// Merchant public website.
website?: string;
// Merchant logo.
logo?: ImageDataUrl;
// The merchant's physical address (to be put into contracts). // The merchant's physical address (to be put into contracts).
address: Location; address: Location;
@ -427,17 +392,10 @@ export namespace MerchantBackend {
// (to be put into contracts). // (to be put into contracts).
jurisdiction: Location; jurisdiction: Location;
// Maximum wire fee this instance is willing to pay. // Use STEFAN curves to determine default fees?
// Can be overridden by the frontend on a per-order basis. // If false, no fees are allowed by default.
default_max_wire_fee: Amount; // Can always be overridden by the frontend on a per-order basis.
use_stefan: boolean;
// Default factor for wire fee amortization calculations.
// Can be overridden by the frontend on a per-order basis.
default_wire_fee_amortization: Integer;
// Maximum deposit fee (sum over all coins) this instance is willing to pay.
// Can be overridden by the frontend on a per-order basis.
default_max_deposit_fee: Amount;
// If the frontend does NOT specify an execution date, how long should // If the frontend does NOT specify an execution date, how long should
// we tell the exchange to wait to aggregate transactions before // we tell the exchange to wait to aggregate transactions before
@ -460,7 +418,14 @@ export namespace MerchantBackend {
// Merchant name corresponding to this instance. // Merchant name corresponding to this instance.
name: string; name: string;
deleted?: boolean; // Type of the user ("business" or "individual").
user_type: MerchantUserType;
// Merchant public website.
website?: string;
// Merchant logo.
logo?: ImageDataUrl;
// Merchant instance this response is about ($INSTANCE) // Merchant instance this response is about ($INSTANCE)
id: string; id: string;
@ -472,8 +437,63 @@ export namespace MerchantBackend {
// specify the desired payment target in /order requests. Note that // specify the desired payment target in /order requests. Note that
// front-ends do not have to support wallets selecting payment targets. // front-ends do not have to support wallets selecting payment targets.
payment_targets: string[]; payment_targets: string[];
// Has this instance been deleted (but not purged)?
deleted: boolean;
} }
//GET /private/instances/$INSTANCE
interface QueryInstancesResponse {
// Merchant name corresponding to this instance.
name: string;
// Type of the user ("business" or "individual").
user_type: MerchantUserType;
// Merchant email for customer contact.
email?: string;
// Merchant public website.
website?: string;
// Merchant logo.
logo?: ImageDataUrl;
// Public key of the merchant/instance, in Crockford Base32 encoding.
merchant_pub: EddsaPublicKey;
// The merchant's physical address (to be put into contracts).
address: Location;
// The jurisdiction under which the merchant conducts its business
// (to be put into contracts).
jurisdiction: Location;
// Use STEFAN curves to determine default fees?
// If false, no fees are allowed by default.
// Can always be overridden by the frontend on a per-order basis.
use_stefan: boolean;
// If the frontend does NOT specify an execution date, how long should
// we tell the exchange to wait to aggregate transactions before
// executing the wire transfer? This delay is added to the current
// time when we generate the advisory execution time for the exchange.
default_wire_transfer_delay: RelativeTime;
// If the frontend does NOT specify a payment deadline, how long should
// offers we make be valid by default?
default_pay_delay: RelativeTime;
// Authentication configuration.
// Does not contain the token when token auth is configured.
auth: {
method: "external" | "token";
};
}
// DELETE /private/instances/$INSTANCE
}
namespace KYC {
//GET /private/instances/$INSTANCE/kyc //GET /private/instances/$INSTANCE/kyc
interface AccountKycRedirects { interface AccountKycRedirects {
// Array of pending KYCs. // Array of pending KYCs.
@ -513,56 +533,76 @@ export namespace MerchantBackend {
exchange_http_status: number; exchange_http_status: number;
} }
//GET /private/instances/$INSTANCE }
interface QueryInstancesResponse {
// The URI where the wallet will send coins. A merchant may have
// multiple accounts, thus this is an array.
accounts: MerchantAccount[];
// Merchant name corresponding to this instance. namespace BankAccounts {
name: string;
// Public key of the merchant/instance, in Crockford Base32 encoding. interface AccountAddDetails {
merchant_pub: EddsaPublicKey;
// The merchant's physical address (to be put into contracts). // payto:// URI of the account.
address: Location; payto_uri: string;
// The jurisdiction under which the merchant conducts its business // URL from where the merchant can download information
// (to be put into contracts). // about incoming wire transfers to this account.
jurisdiction: Location; credit_facade_url?: string;
// Maximum wire fee this instance is willing to pay. // Credentials to use when accessing the credit facade.
// Can be overridden by the frontend on a per-order basis. // Never returned on a GET (as this may be somewhat
default_max_wire_fee: Amount; // sensitive data). Can be set in POST
// or PATCH requests to update (or delete) credentials.
// To really delete credentials, set them to the type: "none".
credit_facade_credentials?: FacadeCredentials;
// Default factor for wire fee amortization calculations.
// Can be overridden by the frontend on a per-order basis.
default_wire_fee_amortization: Integer;
// Maximum deposit fee (sum over all coins) this instance is willing to pay.
// Can be overridden by the frontend on a per-order basis.
default_max_deposit_fee: Amount;
// If the frontend does NOT specify an execution date, how long should
// we tell the exchange to wait to aggregate transactions before
// executing the wire transfer? This delay is added to the current
// time when we generate the advisory execution time for the exchange.
default_wire_transfer_delay: RelativeTime;
// If the frontend does NOT specify a payment deadline, how long should
// offers we make be valid by default?
default_pay_delay: RelativeTime;
// Authentication configuration.
// Does not contain the token when token auth is configured.
auth: {
method: "external" | "token";
token?: string;
};
} }
interface MerchantAccount { type FacadeCredentials =
| NoFacadeCredentials
| BasicAuthFacadeCredentials;
interface NoFacadeCredentials {
type: "none";
}
interface BasicAuthFacadeCredentials {
type: "basic";
// Username to use to authenticate
username: string;
// Password to use to authenticate
password: string;
}
interface AccountAddResponse {
// Hash over the wire details (including over the salt).
h_wire: HashCode;
// Salt used to compute h_wire.
salt: HashCode;
}
interface AccountPatchDetails {
// URL from where the merchant can download information
// about incoming wire transfers to this account.
credit_facade_url?: string;
// Credentials to use when accessing the credit facade.
// Never returned on a GET (as this may be somewhat
// sensitive data). Can be set in POST
// or PATCH requests to update (or delete) credentials.
// To really delete credentials, set them to the type: "none".
credit_facade_credentials?: FacadeCredentials;
}
interface AccountsSummaryResponse {
// List of accounts that are known for the instance.
accounts: BankAccountEntry[];
}
interface BankAccountEntry {
// payto:// URI of the account. // payto:// URI of the account.
payto_uri: string; payto_uri: string;
@ -587,7 +627,6 @@ export namespace MerchantBackend {
active: boolean; active: boolean;
} }
// DELETE /private/instances/$INSTANCE
} }
namespace Products { namespace Products {
@ -957,6 +996,10 @@ export namespace MerchantBackend {
// high entropy to prevent adversarial claims (like it is // high entropy to prevent adversarial claims (like it is
// if the backend auto-generates one). Default is 'true'. // if the backend auto-generates one). Default is 'true'.
create_token?: boolean; create_token?: boolean;
// OTP device ID to associate with the order.
// This parameter is optional.
otp_id?: string;
} }
type Order = MinimalOrderDetail | ContractTerms; type Order = MinimalOrderDetail | ContractTerms;
@ -1031,9 +1074,9 @@ export namespace MerchantBackend {
} }
} }
namespace Tips { namespace Rewards {
// GET /private/reserves // GET /private/reserves
interface TippingReserveStatus { interface RewardReserveStatus {
// Array of all known reserves (possibly empty!) // Array of all known reserves (possibly empty!)
reserves: ReserveStatusEntry[]; reserves: ReserveStatusEntry[];
} }
@ -1057,7 +1100,7 @@ export namespace MerchantBackend {
// Amount picked up so far. // Amount picked up so far.
pickup_amount: Amount; pickup_amount: Amount;
// Amount approved for tips that exceeds the pickup_amount. // Amount approved for rewards that exceeds the pickup_amount.
committed_amount: Amount; committed_amount: Amount;
// Is this reserve active (false if it was deleted but not purged) // Is this reserve active (false if it was deleted but not purged)
@ -1068,7 +1111,7 @@ export namespace MerchantBackend {
// Amount that the merchant promises to put into the reserve // Amount that the merchant promises to put into the reserve
initial_balance: Amount; initial_balance: Amount;
// Exchange the merchant intends to use for tipping // Exchange the merchant intends to use for reward
exchange_url: string; exchange_url: string;
// Desired wire method, for example "iban" or "x-taler-bank" // Desired wire method, for example "iban" or "x-taler-bank"
@ -1081,30 +1124,30 @@ export namespace MerchantBackend {
// Wire accounts of the exchange where to transfer the funds. // Wire accounts of the exchange where to transfer the funds.
accounts: WireAccount[]; accounts: WireAccount[];
} }
interface TipCreateRequest { interface RewardCreateRequest {
// Amount that the customer should be tipped // Amount that the customer should be reward
amount: Amount; amount: Amount;
// Justification for giving the tip // Justification for giving the reward
justification: string; justification: string;
// URL that the user should be directed to after tipping, // URL that the user should be directed to after rewarding,
// will be included in the tip_token. // will be included in the reward_token.
next_url: string; next_url: string;
} }
interface TipCreateConfirmation { interface RewardCreateConfirmation {
// Unique tip identifier for the tip that was created. // Unique reward identifier for the reward that was created.
tip_id: HashCode; reward_id: HashCode;
// taler://tip URI for the tip // taler://reward URI for the reward
taler_tip_uri: string; taler_reward_uri: string;
// URL that will directly trigger processing // URL that will directly trigger processing
// the tip when the browser is redirected to it // the reward when the browser is redirected to it
tip_status_url: string; reward_status_url: string;
// when does the tip expire // when does the reward expire
tip_expiration: Timestamp; reward_expiration: Timestamp;
} }
interface ReserveDetail { interface ReserveDetail {
@ -1124,12 +1167,12 @@ export namespace MerchantBackend {
// Amount picked up so far. // Amount picked up so far.
pickup_amount: Amount; pickup_amount: Amount;
// Amount approved for tips that exceeds the pickup_amount. // Amount approved for rewards that exceeds the pickup_amount.
committed_amount: Amount; committed_amount: Amount;
// Array of all tips created by this reserves (possibly empty!). // Array of all rewards created by this reserves (possibly empty!).
// Only present if asked for explicitly. // Only present if asked for explicitly.
tips?: TipStatusEntry[]; rewards?: RewardStatusEntry[];
// Is this reserve active (false if it was deleted but not purged)? // Is this reserve active (false if it was deleted but not purged)?
active: boolean; active: boolean;
@ -1144,31 +1187,31 @@ export namespace MerchantBackend {
exchange_url: string; exchange_url: string;
} }
interface TipStatusEntry { interface RewardStatusEntry {
// Unique identifier for the tip. // Unique identifier for the reward.
tip_id: HashCode; reward_id: HashCode;
// Total amount of the tip that can be withdrawn. // Total amount of the reward that can be withdrawn.
total_amount: Amount; total_amount: Amount;
// Human-readable reason for why the tip was granted. // Human-readable reason for why the reward was granted.
reason: string; reason: string;
} }
interface TipDetails { interface RewardDetails {
// Amount that we authorized for this tip. // Amount that we authorized for this reward.
total_authorized: Amount; total_authorized: Amount;
// Amount that was picked up by the user already. // Amount that was picked up by the user already.
total_picked_up: Amount; total_picked_up: Amount;
// Human-readable reason given when authorizing the tip. // Human-readable reason given when authorizing the reward.
reason: string; reason: string;
// Timestamp indicating when the tip is set to expire (may be in the past). // Timestamp indicating when the reward is set to expire (may be in the past).
expiration: Timestamp; expiration: Timestamp;
// Reserve public key from which the tip is funded. // Reserve public key from which the reward is funded.
reserve_pub: EddsaPublicKey; reserve_pub: EddsaPublicKey;
// Array showing the pickup operations of the wallet (possibly empty!). // Array showing the pickup operations of the wallet (possibly empty!).
@ -1239,6 +1282,63 @@ export namespace MerchantBackend {
} }
} }
namespace OTP {
interface OtpDeviceAddDetails {
// Device ID to use.
otp_device_id: string;
// Human-readable description for the device.
otp_description: string;
// A base64-encoded key
otp_key: string;
// Algorithm for computing the POS confirmation.
otp_algorithm: Integer;
// Counter for counter-based OTP devices.
otp_ctr?: Integer;
}
interface OtpDevicePatchDetails {
// Human-readable description for the device.
otp_description: string;
// A base64-encoded key
otp_key: string | undefined;
// Algorithm for computing the POS confirmation.
otp_algorithm: Integer;
// Counter for counter-based OTP devices.
otp_ctr?: Integer;
}
interface OtpDeviceSummaryResponse {
// Array of devices that are present in our backend.
otp_devices: OtpDeviceEntry[];
}
interface OtpDeviceEntry {
// Device identifier.
otp_device_id: string;
// Human-readable description for the device.
device_description: string;
}
interface OtpDeviceDetails {
// Human-readable description for the device.
device_description: string;
// Algorithm for computing the POS confirmation.
otp_algorithm: Integer;
// Counter for counter-based OTP devices.
otp_ctr?: Integer;
}
}
namespace Template { namespace Template {
interface TemplateAddDetails { interface TemplateAddDetails {
// Template ID to use. // Template ID to use.
@ -1247,12 +1347,9 @@ export namespace MerchantBackend {
// Human-readable description for the template. // Human-readable description for the template.
template_description: string; template_description: string;
// A base64-encoded key of the point-of-sale. // OTP device ID.
// This parameter is optional. // This parameter is optional.
pos_key?: string; otp_id?: string;
// Algorithm for computing the POS confirmation, 0 for none.
pos_algorithm?: number;
// Additional information in a separate template. // Additional information in a separate template.
template_contract: TemplateContractDetails; template_contract: TemplateContractDetails;
@ -1276,12 +1373,9 @@ export namespace MerchantBackend {
// Human-readable description for the template. // Human-readable description for the template.
template_description: string; template_description: string;
// A base64-encoded key of the point-of-sale. // OTP device ID.
// This parameter is optional. // This parameter is optional.
pos_key?: string; otp_id?: string;
// Algorithm for computing the POS confirmation, 0 for none.
pos_algorithm?: Integer;
// Additional information in a separate template. // Additional information in a separate template.
template_contract: TemplateContractDetails; template_contract: TemplateContractDetails;
@ -1304,12 +1398,9 @@ export namespace MerchantBackend {
// Human-readable description for the template. // Human-readable description for the template.
template_description: string; template_description: string;
// A base64-encoded key of the point-of-sale. // OTP device ID.
// This parameter is optional. // This parameter is optional.
pos_key?: string; otp_id?: string;
// Algorithm for computing the POS confirmation, 0 for none.
pos_algorithm?: Integer;
// Additional information in a separate template. // Additional information in a separate template.
template_contract: TemplateContractDetails; template_contract: TemplateContractDetails;
@ -1424,21 +1515,6 @@ export namespace MerchantBackend {
// Maximum total deposit fee accepted by the merchant for this contract // Maximum total deposit fee accepted by the merchant for this contract
max_fee: Amount; max_fee: Amount;
// Maximum wire fee accepted by the merchant (customer share to be
// divided by the 'wire_fee_amortization' factor, and further reduced
// if deposit fees are below 'max_fee'). Default if missing is zero.
max_wire_fee: Amount;
// Over how many customer transactions does the merchant expect to
// amortize wire fees on average? If the exchange's wire fee is
// above 'max_wire_fee', the difference is divided by this number
// to compute the expected customer's contribution to the wire fee.
// The customer's contribution may further be reduced by the difference
// between the 'max_fee' and the sum of the actual deposit fees.
// Optional, default value if missing is 1. 0 and negative values are
// invalid and also interpreted as 1.
wire_fee_amortization: number;
// List of products that are part of the purchase (see Product). // List of products that are part of the purchase (see Product).
products: Product[]; products: Product[];

View File

@ -33,8 +33,9 @@ import {
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { useApiContext } from "@gnu-taler/web-util/browser"; import { useApiContext } from "@gnu-taler/web-util/browser";
export function useMatchMutate(): ( export function useMatchMutate(): (
re: RegExp, re?: RegExp,
value?: unknown, value?: unknown,
) => Promise<any> { ) => Promise<any> {
const { cache, mutate } = useSWRConfig(); const { cache, mutate } = useSWRConfig();
@ -45,13 +46,19 @@ export function useMatchMutate(): (
); );
} }
return function matchRegexMutate(re: RegExp, value?: unknown) { return function matchRegexMutate(re?: RegExp) {
const allKeys = Array.from(cache.keys()); return mutate((key) => {
const keys = allKeys.filter((key) => re.test(key)); // evict if no key or regex === all
const mutations = keys.map((key) => { if (!key || !re) return true
return mutate(key, value, true); // match string
if (typeof key === 'string' && re.test(key)) return true
// record or object have the path at [0]
if (typeof key === 'object' && re.test(key[0])) return true
//key didn't match regex
return false
}, undefined, {
revalidate: true,
}); });
return Promise.all(mutations);
}; };
} }
@ -106,32 +113,32 @@ interface useBackendInstanceRequestType {
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
reserveDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; reserveDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
tipsDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; rewardsDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
multiFetcher: <T>(url: string[]) => Promise<HttpResponseOk<T>[]>; multiFetcher: <T>(params: [url: string[]]) => Promise<HttpResponseOk<T>[]>;
orderFetcher: <T>( orderFetcher: <T>(
endpoint: string, params: [endpoint: string,
paid?: YesOrNo, paid?: YesOrNo,
refunded?: YesOrNo, refunded?: YesOrNo,
wired?: YesOrNo, wired?: YesOrNo,
searchDate?: Date, searchDate?: Date,
delta?: number, delta?: number,]
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
transferFetcher: <T>( transferFetcher: <T>(
endpoint: string, params: [endpoint: string,
payto_uri?: string, payto_uri?: string,
verified?: string, verified?: string,
position?: string, position?: string,
delta?: number, delta?: number,]
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
templateFetcher: <T>( templateFetcher: <T>(
endpoint: string, params: [endpoint: string,
position?: string, position?: string,
delta?: number, delta?: number]
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
webhookFetcher: <T>( webhookFetcher: <T>(
endpoint: string, params: [endpoint: string,
position?: string, position?: string,
delta?: number, delta?: number]
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
} }
interface useBackendBaseRequestType { interface useBackendBaseRequestType {
@ -147,7 +154,7 @@ export function useCredentialsChecker() {
const { request } = useApiContext(); const { request } = useApiContext();
//check against instance details endpoint //check against instance details endpoint
//while merchant backend doesn't have a login endpoint //while merchant backend doesn't have a login endpoint
return async function testLogin( async function testLogin(
instance: string, instance: string,
token: string, token: string,
): Promise<{ ): Promise<{
@ -167,6 +174,7 @@ export function useCredentialsChecker() {
return { valid: false, cause: ErrorType.UNEXPECTED }; return { valid: false, cause: ErrorType.UNEXPECTED };
} }
}; };
return testLogin
} }
/** /**
@ -212,8 +220,9 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
const multiFetcher = useCallback( const multiFetcher = useCallback(
function multiFetcherImpl<T>( function multiFetcherImpl<T>(
endpoints: string[], args: [endpoints: string[]],
): Promise<HttpResponseOk<T>[]> { ): Promise<HttpResponseOk<T>[]> {
const [endpoints] = args
return Promise.all( return Promise.all(
endpoints.map((endpoint) => endpoints.map((endpoint) =>
requestHandler<T>(baseUrl, endpoint, { token }), requestHandler<T>(baseUrl, endpoint, { token }),
@ -232,13 +241,14 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
const orderFetcher = useCallback( const orderFetcher = useCallback(
function orderFetcherImpl<T>( function orderFetcherImpl<T>(
endpoint: string, args: [endpoint: string,
paid?: YesOrNo, paid?: YesOrNo,
refunded?: YesOrNo, refunded?: YesOrNo,
wired?: YesOrNo, wired?: YesOrNo,
searchDate?: Date, searchDate?: Date,
delta?: number, delta?: number,]
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
const [endpoint, paid, refunded, wired, searchDate, delta] = args
const date_s = const date_s =
delta && delta < 0 && searchDate delta && delta < 0 && searchDate
? (searchDate.getTime() / 1000) + 1 ? (searchDate.getTime() / 1000) + 1
@ -260,7 +270,7 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { return requestHandler<T>(baseUrl, endpoint, {
params: { params: {
tips: "yes", rewards: "yes",
}, },
token, token,
}); });
@ -268,8 +278,8 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
[baseUrl, token], [baseUrl, token],
); );
const tipsDetailFetcher = useCallback( const rewardsDetailFetcher = useCallback(
function tipsDetailFetcherImpl<T>( function rewardsDetailFetcherImpl<T>(
endpoint: string, endpoint: string,
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { return requestHandler<T>(baseUrl, endpoint, {
@ -284,12 +294,13 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
const transferFetcher = useCallback( const transferFetcher = useCallback(
function transferFetcherImpl<T>( function transferFetcherImpl<T>(
endpoint: string, args: [endpoint: string,
payto_uri?: string, payto_uri?: string,
verified?: string, verified?: string,
position?: string, position?: string,
delta?: number, delta?: number,]
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
const [endpoint, payto_uri, verified, position, delta] = args
const params: any = {}; const params: any = {};
if (payto_uri !== undefined) params.payto_uri = payto_uri; if (payto_uri !== undefined) params.payto_uri = payto_uri;
if (verified !== undefined) params.verified = verified; if (verified !== undefined) params.verified = verified;
@ -305,10 +316,11 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
const templateFetcher = useCallback( const templateFetcher = useCallback(
function templateFetcherImpl<T>( function templateFetcherImpl<T>(
endpoint: string, args: [endpoint: string,
position?: string, position?: string,
delta?: number, delta?: number,]
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
const [endpoint, position, delta] = args
const params: any = {}; const params: any = {};
if (delta !== undefined) { if (delta !== undefined) {
params.limit = delta; params.limit = delta;
@ -322,10 +334,11 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
const webhookFetcher = useCallback( const webhookFetcher = useCallback(
function webhookFetcherImpl<T>( function webhookFetcherImpl<T>(
endpoint: string, args: [endpoint: string,
position?: string, position?: string,
delta?: number, delta?: number,]
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
const [endpoint, position, delta] = args
const params: any = {}; const params: any = {};
if (delta !== undefined) { if (delta !== undefined) {
params.limit = delta; params.limit = delta;
@ -343,7 +356,7 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
multiFetcher, multiFetcher,
orderFetcher, orderFetcher,
reserveDetailFetcher, reserveDetailFetcher,
tipsDetailFetcher, rewardsDetailFetcher,
transferFetcher, transferFetcher,
templateFetcher, templateFetcher,
webhookFetcher, webhookFetcher,

View File

@ -0,0 +1,217 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 {
HttpResponse,
HttpResponseOk,
HttpResponsePaginated,
RequestError,
} from "@gnu-taler/web-util/browser";
import { useEffect, useState } from "preact/hooks";
import { MerchantBackend } from "../declaration.js";
import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import _useSWR, { SWRHook } from "swr";
const useSWR = _useSWR as unknown as SWRHook;
// const MOCKED_ACCOUNTS: Record<string, MerchantBackend.BankAccounts.AccountAddDetails> = {
// "hwire1": {
// h_wire: "hwire1",
// payto_uri: "payto://fake/iban/123",
// salt: "qwe",
// },
// "hwire2": {
// h_wire: "hwire2",
// payto_uri: "payto://fake/iban/123",
// salt: "qwe2",
// },
// }
export function useBankAccountAPI(): BankAccountAPI {
const mutateAll = useMatchMutate();
const { request } = useBackendInstanceRequest();
const createBankAccount = async (
data: MerchantBackend.BankAccounts.AccountAddDetails,
): Promise<HttpResponseOk<void>> => {
// MOCKED_ACCOUNTS[data.h_wire] = data
// return Promise.resolve({ ok: true, data: undefined });
const res = await request<void>(`/private/accounts`, {
method: "POST",
data,
});
await mutateAll(/.*private\/accounts.*/);
return res;
};
const updateBankAccount = async (
h_wire: string,
data: MerchantBackend.BankAccounts.AccountPatchDetails,
): Promise<HttpResponseOk<void>> => {
// MOCKED_ACCOUNTS[h_wire].credit_facade_credentials = data.credit_facade_credentials
// MOCKED_ACCOUNTS[h_wire].credit_facade_url = data.credit_facade_url
// return Promise.resolve({ ok: true, data: undefined });
const res = await request<void>(`/private/accounts/${h_wire}`, {
method: "PATCH",
data,
});
await mutateAll(/.*private\/accounts.*/);
return res;
};
const deleteBankAccount = async (
h_wire: string,
): Promise<HttpResponseOk<void>> => {
// delete MOCKED_ACCOUNTS[h_wire]
// return Promise.resolve({ ok: true, data: undefined });
const res = await request<void>(`/private/accounts/${h_wire}`, {
method: "DELETE",
});
await mutateAll(/.*private\/accounts.*/);
return res;
};
return {
createBankAccount,
updateBankAccount,
deleteBankAccount,
};
}
export interface BankAccountAPI {
createBankAccount: (
data: MerchantBackend.BankAccounts.AccountAddDetails,
) => Promise<HttpResponseOk<void>>;
updateBankAccount: (
id: string,
data: MerchantBackend.BankAccounts.AccountPatchDetails,
) => Promise<HttpResponseOk<void>>;
deleteBankAccount: (id: string) => Promise<HttpResponseOk<void>>;
}
export interface InstanceBankAccountFilter {
}
export function useInstanceBankAccounts(
args?: InstanceBankAccountFilter,
updatePosition?: (id: string) => void,
): HttpResponsePaginated<
MerchantBackend.BankAccounts.AccountsSummaryResponse,
MerchantBackend.ErrorDetail
> {
// return {
// ok: true,
// loadMore() { },
// loadMorePrev() { },
// data: {
// accounts: Object.values(MOCKED_ACCOUNTS).map(e => ({
// ...e,
// active: true,
// }))
// }
// }
const { fetcher } = useBackendInstanceRequest();
const [pageAfter, setPageAfter] = useState(1);
const totalAfter = pageAfter * PAGE_SIZE;
const {
data: afterData,
error: afterError,
isValidating: loadingAfter,
} = useSWR<
HttpResponseOk<MerchantBackend.BankAccounts.AccountsSummaryResponse>,
RequestError<MerchantBackend.ErrorDetail>
>([`/private/accounts`], fetcher);
const [lastAfter, setLastAfter] = useState<
HttpResponse<
MerchantBackend.BankAccounts.AccountsSummaryResponse,
MerchantBackend.ErrorDetail
>
>({ loading: true });
useEffect(() => {
if (afterData) setLastAfter(afterData);
}, [afterData /*, beforeData*/]);
if (afterError) return afterError.cause;
// if the query returns less that we ask, then we have reach the end or beginning
const isReachingEnd =
afterData && afterData.data.accounts.length < totalAfter;
const isReachingStart = false;
const pagination = {
isReachingEnd,
isReachingStart,
loadMore: () => {
if (!afterData || isReachingEnd) return;
if (afterData.data.accounts.length < MAX_RESULT_SIZE) {
setPageAfter(pageAfter + 1);
} else {
const from = `${afterData.data.accounts[afterData.data.accounts.length - 1]
.h_wire
}`;
if (from && updatePosition) updatePosition(from);
}
},
loadMorePrev: () => {
},
};
const accounts = !afterData ? [] : (afterData || lastAfter).data.accounts;
if (loadingAfter /* || loadingBefore */)
return { loading: true, data: { accounts } };
if (/*beforeData &&*/ afterData) {
return { ok: true, data: { accounts }, ...pagination };
}
return { loading: true };
}
export function useBankAccountDetails(
h_wire: string,
): HttpResponse<
MerchantBackend.BankAccounts.BankAccountEntry,
MerchantBackend.ErrorDetail
> {
// return {
// ok: true,
// data: {
// ...MOCKED_ACCOUNTS[h_wire],
// active: true,
// }
// }
const { fetcher } = useBackendInstanceRequest();
const { data, error, isValidating } = useSWR<
HttpResponseOk<MerchantBackend.BankAccounts.BankAccountEntry>,
RequestError<MerchantBackend.ErrorDetail>
>([`/private/accounts/${h_wire}`], fetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
});
if (isValidating) return { loading: true, data: data?.data };
if (data) {
return data;
}
if (error) return error.cause;
return { loading: true };
}

View File

@ -19,9 +19,10 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { StateUpdater, useCallback, useState } from "preact/hooks"; import { StateUpdater, useCallback, useEffect, useState } from "preact/hooks";
import { ValueOrFunction } from "../utils/types.js"; import { ValueOrFunction } from "../utils/types.js";
import { useMemoryStorage } from "@gnu-taler/web-util/browser"; import { useMemoryStorage } from "@gnu-taler/web-util/browser";
import { useMatchMutate } from "./backend.js";
const calculateRootPath = () => { const calculateRootPath = () => {
const rootPath = const rootPath =
@ -56,8 +57,22 @@ export function useBackendDefaultToken(
): [string | undefined, ((d: string | undefined) => void)] { ): [string | undefined, ((d: string | undefined) => void)] {
// uncomment for testing // uncomment for testing
initialValue = "secret-token:secret" as string | undefined initialValue = "secret-token:secret" as string | undefined
const { update, value } = useMemoryStorage(`backend-token`, initialValue) const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token`, initialValue)
return [value, update]; const clearCache = useMatchMutate()
useEffect(() => {
clearCache()
}, [token])
function updateToken(
value: (string | undefined)
): void {
if (value === undefined) {
reset()
} else {
setToken(value)
}
}
return [token, updateToken];
} }
export function useBackendInstanceToken( export function useBackendInstanceToken(
@ -73,14 +88,12 @@ export function useBackendInstanceToken(
function updateToken( function updateToken(
value: (string | undefined) value: (string | undefined)
): void { ): void {
console.log("seeting token", value)
if (value === undefined) { if (value === undefined) {
reset() reset()
} else { } else {
setToken(value) setToken(value)
} }
} }
console.log("token", token)
return [token, updateToken]; return [token, updateToken];
} }

View File

@ -113,7 +113,7 @@ describe("instance api interaction with details", () => {
name: "instance_name", name: "instance_name",
auth: { auth: {
method: "token", method: "token",
token: "not-secret", // token: "not-secret",
}, },
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });
@ -154,7 +154,7 @@ describe("instance api interaction with details", () => {
name: "instance_name", name: "instance_name",
auth: { auth: {
method: "token", method: "token",
token: "secret", // token: "secret",
}, },
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });
@ -190,7 +190,7 @@ describe("instance api interaction with details", () => {
name: "instance_name", name: "instance_name",
auth: { auth: {
method: "token", method: "token",
token: "not-secret", // token: "not-secret",
}, },
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });

View File

@ -198,6 +198,7 @@ export function useInstanceDetails(): HttpResponse<
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: false, revalidateOnReconnect: false,
refreshWhenOffline: false, refreshWhenOffline: false,
revalidateIfStale: false,
errorRetryCount: 0, errorRetryCount: 0,
errorRetryInterval: 1, errorRetryInterval: 1,
shouldRetryOnError: false, shouldRetryOnError: false,
@ -211,7 +212,7 @@ export function useInstanceDetails(): HttpResponse<
type KYCStatus = type KYCStatus =
| { type: "ok" } | { type: "ok" }
| { type: "redirect"; status: MerchantBackend.Instances.AccountKycRedirects }; | { type: "redirect"; status: MerchantBackend.KYC.AccountKycRedirects };
export function useInstanceKYCDetails(): HttpResponse< export function useInstanceKYCDetails(): HttpResponse<
KYCStatus, KYCStatus,
@ -220,7 +221,7 @@ export function useInstanceKYCDetails(): HttpResponse<
const { fetcher } = useBackendInstanceRequest(); const { fetcher } = useBackendInstanceRequest();
const { data, error } = useSWR< const { data, error } = useSWR<
HttpResponseOk<MerchantBackend.Instances.AccountKycRedirects>, HttpResponseOk<MerchantBackend.KYC.AccountKycRedirects>,
RequestError<MerchantBackend.ErrorDetail> RequestError<MerchantBackend.ErrorDetail>
>([`/private/kyc`], fetcher, { >([`/private/kyc`], fetcher, {
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,

View File

@ -0,0 +1,223 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 {
HttpResponse,
HttpResponseOk,
HttpResponsePaginated,
RequestError,
} from "@gnu-taler/web-util/browser";
import { useEffect, useState } from "preact/hooks";
import { MerchantBackend } from "../declaration.js";
import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import _useSWR, { SWRHook } from "swr";
const useSWR = _useSWR as unknown as SWRHook;
const MOCKED_DEVICES: Record<string, MerchantBackend.OTP.OtpDeviceAddDetails> = {
"1": {
otp_description: "first device",
otp_algorithm: 1,
otp_device_id: "1",
otp_key: "123",
},
"2": {
otp_description: "second device",
otp_algorithm: 0,
otp_device_id: "2",
otp_key: "456",
}
}
export function useOtpDeviceAPI(): OtpDeviceAPI {
const mutateAll = useMatchMutate();
const { request } = useBackendInstanceRequest();
const createOtpDevice = async (
data: MerchantBackend.OTP.OtpDeviceAddDetails,
): Promise<HttpResponseOk<void>> => {
// MOCKED_DEVICES[data.otp_device_id] = data
// return Promise.resolve({ ok: true, data: undefined });
const res = await request<void>(`/private/otp-devices`, {
method: "POST",
data,
});
await mutateAll(/.*private\/otp-devices.*/);
return res;
};
const updateOtpDevice = async (
deviceId: string,
data: MerchantBackend.OTP.OtpDevicePatchDetails,
): Promise<HttpResponseOk<void>> => {
// MOCKED_DEVICES[deviceId].otp_algorithm = data.otp_algorithm
// MOCKED_DEVICES[deviceId].otp_ctr = data.otp_ctr
// MOCKED_DEVICES[deviceId].otp_device_description = data.otp_device_description
// MOCKED_DEVICES[deviceId].otp_key = data.otp_key
// return Promise.resolve({ ok: true, data: undefined });
const res = await request<void>(`/private/otp-devices/${deviceId}`, {
method: "PATCH",
data,
});
await mutateAll(/.*private\/otp-devices.*/);
return res;
};
const deleteOtpDevice = async (
deviceId: string,
): Promise<HttpResponseOk<void>> => {
// delete MOCKED_DEVICES[deviceId]
// return Promise.resolve({ ok: true, data: undefined });
const res = await request<void>(`/private/otp-devices/${deviceId}`, {
method: "DELETE",
});
await mutateAll(/.*private\/otp-devices.*/);
return res;
};
return {
createOtpDevice,
updateOtpDevice,
deleteOtpDevice,
};
}
export interface OtpDeviceAPI {
createOtpDevice: (
data: MerchantBackend.OTP.OtpDeviceAddDetails,
) => Promise<HttpResponseOk<void>>;
updateOtpDevice: (
id: string,
data: MerchantBackend.OTP.OtpDevicePatchDetails,
) => Promise<HttpResponseOk<void>>;
deleteOtpDevice: (id: string) => Promise<HttpResponseOk<void>>;
}
export interface InstanceOtpDeviceFilter {
}
export function useInstanceOtpDevices(
args?: InstanceOtpDeviceFilter,
updatePosition?: (id: string) => void,
): HttpResponsePaginated<
MerchantBackend.OTP.OtpDeviceSummaryResponse,
MerchantBackend.ErrorDetail
> {
// return {
// ok: true,
// loadMore: () => { },
// loadMorePrev: () => { },
// data: {
// otp_devices: Object.values(MOCKED_DEVICES).map(d => ({
// device_description: d.otp_device_description,
// otp_device_id: d.otp_device_id
// }))
// }
// }
const { fetcher } = useBackendInstanceRequest();
const [pageAfter, setPageAfter] = useState(1);
const totalAfter = pageAfter * PAGE_SIZE;
const {
data: afterData,
error: afterError,
isValidating: loadingAfter,
} = useSWR<
HttpResponseOk<MerchantBackend.OTP.OtpDeviceSummaryResponse>,
RequestError<MerchantBackend.ErrorDetail>
>([`/private/otp-devices`], fetcher);
const [lastAfter, setLastAfter] = useState<
HttpResponse<
MerchantBackend.OTP.OtpDeviceSummaryResponse,
MerchantBackend.ErrorDetail
>
>({ loading: true });
useEffect(() => {
if (afterData) setLastAfter(afterData);
}, [afterData /*, beforeData*/]);
if (afterError) return afterError.cause;
// if the query returns less that we ask, then we have reach the end or beginning
const isReachingEnd =
afterData && afterData.data.otp_devices.length < totalAfter;
const isReachingStart = false;
const pagination = {
isReachingEnd,
isReachingStart,
loadMore: () => {
if (!afterData || isReachingEnd) return;
if (afterData.data.otp_devices.length < MAX_RESULT_SIZE) {
setPageAfter(pageAfter + 1);
} else {
const from = `${afterData.data.otp_devices[afterData.data.otp_devices.length - 1]
.otp_device_id
}`;
if (from && updatePosition) updatePosition(from);
}
},
loadMorePrev: () => {
},
};
const otp_devices = !afterData ? [] : (afterData || lastAfter).data.otp_devices;
if (loadingAfter /* || loadingBefore */)
return { loading: true, data: { otp_devices } };
if (/*beforeData &&*/ afterData) {
return { ok: true, data: { otp_devices }, ...pagination };
}
return { loading: true };
}
export function useOtpDeviceDetails(
deviceId: string,
): HttpResponse<
MerchantBackend.OTP.OtpDeviceDetails,
MerchantBackend.ErrorDetail
> {
// return {
// ok: true,
// data: {
// device_description: MOCKED_DEVICES[deviceId].otp_device_description,
// otp_algorithm: MOCKED_DEVICES[deviceId].otp_algorithm,
// otp_ctr: MOCKED_DEVICES[deviceId].otp_ctr
// }
// }
const { fetcher } = useBackendInstanceRequest();
const { data, error, isValidating } = useSWR<
HttpResponseOk<MerchantBackend.OTP.OtpDeviceDetails>,
RequestError<MerchantBackend.ErrorDetail>
>([`/private/otp-devices/${deviceId}`], fetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
});
if (isValidating) return { loading: true, data: data?.data };
if (data) {
return data;
}
if (error) return error.cause;
return { loading: true };
}

View File

@ -25,16 +25,16 @@ import {
useInstanceReserves, useInstanceReserves,
useReserveDetails, useReserveDetails,
useReservesAPI, useReservesAPI,
useTipDetails, useRewardDetails,
} from "./reserves.js"; } from "./reserves.js";
import { ApiMockEnvironment } from "./testing.js"; import { ApiMockEnvironment } from "./testing.js";
import { import {
API_AUTHORIZE_TIP, API_AUTHORIZE_REWARD,
API_AUTHORIZE_TIP_FOR_RESERVE, API_AUTHORIZE_REWARD_FOR_RESERVE,
API_CREATE_RESERVE, API_CREATE_RESERVE,
API_DELETE_RESERVE, API_DELETE_RESERVE,
API_GET_RESERVE_BY_ID, API_GET_RESERVE_BY_ID,
API_GET_TIP_BY_ID, API_GET_REWARD_BY_ID,
API_LIST_RESERVES, API_LIST_RESERVES,
} from "./urls.js"; } from "./urls.js";
import * as tests from "@gnu-taler/web-util/testing"; import * as tests from "@gnu-taler/web-util/testing";
@ -48,7 +48,7 @@ describe("reserve api interaction with listing", () => {
reserves: [ reserves: [
{ {
reserve_pub: "11", reserve_pub: "11",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
], ],
}, },
}); });
@ -89,10 +89,10 @@ describe("reserve api interaction with listing", () => {
reserves: [ reserves: [
{ {
reserve_pub: "11", reserve_pub: "11",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
{ {
reserve_pub: "22", reserve_pub: "22",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
], ],
}, },
}); });
@ -115,10 +115,10 @@ describe("reserve api interaction with listing", () => {
reserves: [ reserves: [
{ {
reserve_pub: "11", reserve_pub: "11",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
{ {
reserve_pub: "22", reserve_pub: "22",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
], ],
}); });
}, },
@ -138,13 +138,13 @@ describe("reserve api interaction with listing", () => {
reserves: [ reserves: [
{ {
reserve_pub: "11", reserve_pub: "11",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
{ {
reserve_pub: "22", reserve_pub: "22",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
{ {
reserve_pub: "33", reserve_pub: "33",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
], ],
}, },
}); });
@ -182,10 +182,10 @@ describe("reserve api interaction with listing", () => {
reserves: [ reserves: [
{ {
reserve_pub: "22", reserve_pub: "22",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
{ {
reserve_pub: "33", reserve_pub: "33",
} as MerchantBackend.Tips.ReserveStatusEntry, } as MerchantBackend.Rewards.ReserveStatusEntry,
], ],
}, },
}); });
@ -213,16 +213,16 @@ describe("reserve api interaction with listing", () => {
}); });
describe("reserve api interaction with details", () => { describe("reserve api interaction with details", () => {
it("should evict cache when adding a tip for a specific reserve", async () => { it("should evict cache when adding a reward for a specific reserve", async () => {
const env = new ApiMockEnvironment(); const env = new ApiMockEnvironment();
env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
response: { response: {
accounts: [{ payto_uri: "payto://here" }], accounts: [{ payto_uri: "payto://here" }],
tips: [{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }], rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
} as MerchantBackend.Tips.ReserveDetail, } as MerchantBackend.Rewards.ReserveDetail,
qparam: { qparam: {
tips: "yes", rewards: "yes",
}, },
}); });
@ -246,37 +246,37 @@ describe("reserve api interaction with details", () => {
if (!query.ok) return; if (!query.ok) return;
expect(query.data).deep.equals({ expect(query.data).deep.equals({
accounts: [{ payto_uri: "payto://here" }], accounts: [{ payto_uri: "payto://here" }],
tips: [{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }], rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
}); });
env.addRequestExpectation(API_AUTHORIZE_TIP_FOR_RESERVE("11"), { env.addRequestExpectation(API_AUTHORIZE_REWARD_FOR_RESERVE("11"), {
request: { request: {
amount: "USD:12", amount: "USD:12",
justification: "not", justification: "not",
next_url: "http://taler.net", next_url: "http://taler.net",
}, },
response: { response: {
tip_id: "id2", reward_id: "id2",
taler_tip_uri: "uri", taler_reward_uri: "uri",
tip_expiration: { t_s: 1 }, reward_expiration: { t_s: 1 },
tip_status_url: "url", reward_status_url: "url",
}, },
}); });
env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
response: { response: {
accounts: [{ payto_uri: "payto://here" }], accounts: [{ payto_uri: "payto://here" }],
tips: [ rewards: [
{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }, { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
{ reason: "not", tip_id: "id2", total_amount: "USD:12" }, { reason: "not", reward_id: "id2", total_amount: "USD:12" },
], ],
} as MerchantBackend.Tips.ReserveDetail, } as MerchantBackend.Rewards.ReserveDetail,
qparam: { qparam: {
tips: "yes", rewards: "yes",
}, },
}); });
api.authorizeTipReserve("11", { api.authorizeRewardReserve("11", {
amount: "USD:12", amount: "USD:12",
justification: "not", justification: "not",
next_url: "http://taler.net", next_url: "http://taler.net",
@ -294,9 +294,9 @@ describe("reserve api interaction with details", () => {
expect(query.data).deep.equals({ expect(query.data).deep.equals({
accounts: [{ payto_uri: "payto://here" }], accounts: [{ payto_uri: "payto://here" }],
tips: [ rewards: [
{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }, { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
{ reason: "not", tip_id: "id2", total_amount: "USD:12" }, { reason: "not", reward_id: "id2", total_amount: "USD:12" },
], ],
}); });
}, },
@ -308,16 +308,16 @@ describe("reserve api interaction with details", () => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
}); });
it("should evict cache when adding a tip for a random reserve", async () => { it("should evict cache when adding a reward for a random reserve", async () => {
const env = new ApiMockEnvironment(); const env = new ApiMockEnvironment();
env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
response: { response: {
accounts: [{ payto_uri: "payto://here" }], accounts: [{ payto_uri: "payto://here" }],
tips: [{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }], rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
} as MerchantBackend.Tips.ReserveDetail, } as MerchantBackend.Rewards.ReserveDetail,
qparam: { qparam: {
tips: "yes", rewards: "yes",
}, },
}); });
@ -341,37 +341,37 @@ describe("reserve api interaction with details", () => {
if (!query.ok) return; if (!query.ok) return;
expect(query.data).deep.equals({ expect(query.data).deep.equals({
accounts: [{ payto_uri: "payto://here" }], accounts: [{ payto_uri: "payto://here" }],
tips: [{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }], rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
}); });
env.addRequestExpectation(API_AUTHORIZE_TIP, { env.addRequestExpectation(API_AUTHORIZE_REWARD, {
request: { request: {
amount: "USD:12", amount: "USD:12",
justification: "not", justification: "not",
next_url: "http://taler.net", next_url: "http://taler.net",
}, },
response: { response: {
tip_id: "id2", reward_id: "id2",
taler_tip_uri: "uri", taler_reward_uri: "uri",
tip_expiration: { t_s: 1 }, reward_expiration: { t_s: 1 },
tip_status_url: "url", reward_status_url: "url",
}, },
}); });
env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
response: { response: {
accounts: [{ payto_uri: "payto://here" }], accounts: [{ payto_uri: "payto://here" }],
tips: [ rewards: [
{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }, { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
{ reason: "not", tip_id: "id2", total_amount: "USD:12" }, { reason: "not", reward_id: "id2", total_amount: "USD:12" },
], ],
} as MerchantBackend.Tips.ReserveDetail, } as MerchantBackend.Rewards.ReserveDetail,
qparam: { qparam: {
tips: "yes", rewards: "yes",
}, },
}); });
api.authorizeTip({ api.authorizeReward({
amount: "USD:12", amount: "USD:12",
justification: "not", justification: "not",
next_url: "http://taler.net", next_url: "http://taler.net",
@ -387,9 +387,9 @@ describe("reserve api interaction with details", () => {
expect(query.data).deep.equals({ expect(query.data).deep.equals({
accounts: [{ payto_uri: "payto://here" }], accounts: [{ payto_uri: "payto://here" }],
tips: [ rewards: [
{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }, { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
{ reason: "not", tip_id: "id2", total_amount: "USD:12" }, { reason: "not", reward_id: "id2", total_amount: "USD:12" },
], ],
}); });
}, },
@ -402,15 +402,15 @@ describe("reserve api interaction with details", () => {
}); });
}); });
describe("reserve api interaction with tip details", () => { describe("reserve api interaction with reward details", () => {
it("should list tips", async () => { it("should list rewards", async () => {
const env = new ApiMockEnvironment(); const env = new ApiMockEnvironment();
env.addRequestExpectation(API_GET_TIP_BY_ID("11"), { env.addRequestExpectation(API_GET_REWARD_BY_ID("11"), {
response: { response: {
total_picked_up: "USD:12", total_picked_up: "USD:12",
reason: "not", reason: "not",
} as MerchantBackend.Tips.TipDetails, } as MerchantBackend.Rewards.RewardDetails,
qparam: { qparam: {
pickups: "yes", pickups: "yes",
}, },
@ -418,7 +418,7 @@ describe("reserve api interaction with tip details", () => {
const hookBehavior = await tests.hookBehaveLikeThis( const hookBehavior = await tests.hookBehaveLikeThis(
() => { () => {
const query = useTipDetails("11"); const query = useRewardDetails("11");
return { query }; return { query };
}, },
{}, {},

View File

@ -31,11 +31,11 @@ export function useReservesAPI(): ReserveMutateAPI {
const { request } = useBackendInstanceRequest(); const { request } = useBackendInstanceRequest();
const createReserve = async ( const createReserve = async (
data: MerchantBackend.Tips.ReserveCreateRequest, data: MerchantBackend.Rewards.ReserveCreateRequest,
): Promise< ): Promise<
HttpResponseOk<MerchantBackend.Tips.ReserveCreateConfirmation> HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>
> => { > => {
const res = await request<MerchantBackend.Tips.ReserveCreateConfirmation>( const res = await request<MerchantBackend.Rewards.ReserveCreateConfirmation>(
`/private/reserves`, `/private/reserves`,
{ {
method: "POST", method: "POST",
@ -49,12 +49,12 @@ export function useReservesAPI(): ReserveMutateAPI {
return res; return res;
}; };
const authorizeTipReserve = async ( const authorizeRewardReserve = async (
pub: string, pub: string,
data: MerchantBackend.Tips.TipCreateRequest, data: MerchantBackend.Rewards.RewardCreateRequest,
): Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>> => { ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => {
const res = await request<MerchantBackend.Tips.TipCreateConfirmation>( const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>(
`/private/reserves/${pub}/authorize-tip`, `/private/reserves/${pub}/authorize-reward`,
{ {
method: "POST", method: "POST",
data, data,
@ -67,11 +67,11 @@ export function useReservesAPI(): ReserveMutateAPI {
return res; return res;
}; };
const authorizeTip = async ( const authorizeReward = async (
data: MerchantBackend.Tips.TipCreateRequest, data: MerchantBackend.Rewards.RewardCreateRequest,
): Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>> => { ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => {
const res = await request<MerchantBackend.Tips.TipCreateConfirmation>( const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>(
`/private/tips`, `/private/rewards`,
{ {
method: "POST", method: "POST",
data, data,
@ -97,33 +97,33 @@ export function useReservesAPI(): ReserveMutateAPI {
return res; return res;
}; };
return { createReserve, authorizeTip, authorizeTipReserve, deleteReserve }; return { createReserve, authorizeReward, authorizeRewardReserve, deleteReserve };
} }
export interface ReserveMutateAPI { export interface ReserveMutateAPI {
createReserve: ( createReserve: (
data: MerchantBackend.Tips.ReserveCreateRequest, data: MerchantBackend.Rewards.ReserveCreateRequest,
) => Promise<HttpResponseOk<MerchantBackend.Tips.ReserveCreateConfirmation>>; ) => Promise<HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>>;
authorizeTipReserve: ( authorizeRewardReserve: (
id: string, id: string,
data: MerchantBackend.Tips.TipCreateRequest, data: MerchantBackend.Rewards.RewardCreateRequest,
) => Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>>; ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>;
authorizeTip: ( authorizeReward: (
data: MerchantBackend.Tips.TipCreateRequest, data: MerchantBackend.Rewards.RewardCreateRequest,
) => Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>>; ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>;
deleteReserve: ( deleteReserve: (
id: string, id: string,
) => Promise<HttpResponse<void, MerchantBackend.ErrorDetail>>; ) => Promise<HttpResponse<void, MerchantBackend.ErrorDetail>>;
} }
export function useInstanceReserves(): HttpResponse< export function useInstanceReserves(): HttpResponse<
MerchantBackend.Tips.TippingReserveStatus, MerchantBackend.Rewards.RewardReserveStatus,
MerchantBackend.ErrorDetail MerchantBackend.ErrorDetail
> { > {
const { fetcher } = useBackendInstanceRequest(); const { fetcher } = useBackendInstanceRequest();
const { data, error, isValidating } = useSWR< const { data, error, isValidating } = useSWR<
HttpResponseOk<MerchantBackend.Tips.TippingReserveStatus>, HttpResponseOk<MerchantBackend.Rewards.RewardReserveStatus>,
RequestError<MerchantBackend.ErrorDetail> RequestError<MerchantBackend.ErrorDetail>
>([`/private/reserves`], fetcher); >([`/private/reserves`], fetcher);
@ -136,13 +136,13 @@ export function useInstanceReserves(): HttpResponse<
export function useReserveDetails( export function useReserveDetails(
reserveId: string, reserveId: string,
): HttpResponse< ): HttpResponse<
MerchantBackend.Tips.ReserveDetail, MerchantBackend.Rewards.ReserveDetail,
MerchantBackend.ErrorDetail MerchantBackend.ErrorDetail
> { > {
const { reserveDetailFetcher } = useBackendInstanceRequest(); const { reserveDetailFetcher } = useBackendInstanceRequest();
const { data, error, isValidating } = useSWR< const { data, error, isValidating } = useSWR<
HttpResponseOk<MerchantBackend.Tips.ReserveDetail>, HttpResponseOk<MerchantBackend.Rewards.ReserveDetail>,
RequestError<MerchantBackend.ErrorDetail> RequestError<MerchantBackend.ErrorDetail>
>([`/private/reserves/${reserveId}`], reserveDetailFetcher, { >([`/private/reserves/${reserveId}`], reserveDetailFetcher, {
refreshInterval: 0, refreshInterval: 0,
@ -158,15 +158,15 @@ export function useReserveDetails(
return { loading: true }; return { loading: true };
} }
export function useTipDetails( export function useRewardDetails(
tipId: string, rewardId: string,
): HttpResponse<MerchantBackend.Tips.TipDetails, MerchantBackend.ErrorDetail> { ): HttpResponse<MerchantBackend.Rewards.RewardDetails, MerchantBackend.ErrorDetail> {
const { tipsDetailFetcher } = useBackendInstanceRequest(); const { rewardsDetailFetcher } = useBackendInstanceRequest();
const { data, error, isValidating } = useSWR< const { data, error, isValidating } = useSWR<
HttpResponseOk<MerchantBackend.Tips.TipDetails>, HttpResponseOk<MerchantBackend.Rewards.RewardDetails>,
RequestError<MerchantBackend.ErrorDetail> RequestError<MerchantBackend.ErrorDetail>
>([`/private/tips/${tipId}`], tipsDetailFetcher, { >([`/private/rewards/${rewardId}`], rewardsDetailFetcher, {
refreshInterval: 0, refreshInterval: 0,
refreshWhenHidden: false, refreshWhenHidden: false,
revalidateOnFocus: false, revalidateOnFocus: false,

View File

@ -139,15 +139,15 @@ export const API_DELETE_PRODUCT = (id: string): Query<unknown, unknown> => ({
//////////////////// ////////////////////
export const API_CREATE_RESERVE: Query< export const API_CREATE_RESERVE: Query<
MerchantBackend.Tips.ReserveCreateRequest, MerchantBackend.Rewards.ReserveCreateRequest,
MerchantBackend.Tips.ReserveCreateConfirmation MerchantBackend.Rewards.ReserveCreateConfirmation
> = { > = {
method: "POST", method: "POST",
url: "http://backend/instances/default/private/reserves", url: "http://backend/instances/default/private/reserves",
}; };
export const API_LIST_RESERVES: Query< export const API_LIST_RESERVES: Query<
unknown, unknown,
MerchantBackend.Tips.TippingReserveStatus MerchantBackend.Rewards.RewardReserveStatus
> = { > = {
method: "GET", method: "GET",
url: "http://backend/instances/default/private/reserves", url: "http://backend/instances/default/private/reserves",
@ -155,34 +155,34 @@ export const API_LIST_RESERVES: Query<
export const API_GET_RESERVE_BY_ID = ( export const API_GET_RESERVE_BY_ID = (
pub: string, pub: string,
): Query<unknown, MerchantBackend.Tips.ReserveDetail> => ({ ): Query<unknown, MerchantBackend.Rewards.ReserveDetail> => ({
method: "GET", method: "GET",
url: `http://backend/instances/default/private/reserves/${pub}`, url: `http://backend/instances/default/private/reserves/${pub}`,
}); });
export const API_GET_TIP_BY_ID = ( export const API_GET_REWARD_BY_ID = (
pub: string, pub: string,
): Query<unknown, MerchantBackend.Tips.TipDetails> => ({ ): Query<unknown, MerchantBackend.Rewards.RewardDetails> => ({
method: "GET", method: "GET",
url: `http://backend/instances/default/private/tips/${pub}`, url: `http://backend/instances/default/private/rewards/${pub}`,
}); });
export const API_AUTHORIZE_TIP_FOR_RESERVE = ( export const API_AUTHORIZE_REWARD_FOR_RESERVE = (
pub: string, pub: string,
): Query< ): Query<
MerchantBackend.Tips.TipCreateRequest, MerchantBackend.Rewards.RewardCreateRequest,
MerchantBackend.Tips.TipCreateConfirmation MerchantBackend.Rewards.RewardCreateConfirmation
> => ({ > => ({
method: "POST", method: "POST",
url: `http://backend/instances/default/private/reserves/${pub}/authorize-tip`, url: `http://backend/instances/default/private/reserves/${pub}/authorize-reward`,
}); });
export const API_AUTHORIZE_TIP: Query< export const API_AUTHORIZE_REWARD: Query<
MerchantBackend.Tips.TipCreateRequest, MerchantBackend.Rewards.RewardCreateRequest,
MerchantBackend.Tips.TipCreateConfirmation MerchantBackend.Rewards.RewardCreateConfirmation
> = { > = {
method: "POST", method: "POST",
url: `http://backend/instances/default/private/tips`, url: `http://backend/instances/default/private/rewards`,
}; };
export const API_DELETE_RESERVE = (id: string): Query<unknown, unknown> => ({ export const API_DELETE_RESERVE = (id: string): Query<unknown, unknown> => ({
@ -211,7 +211,7 @@ export const API_GET_INSTANCE_BY_ID = (
export const API_GET_INSTANCE_KYC_BY_ID = ( export const API_GET_INSTANCE_KYC_BY_ID = (
id: string, id: string,
): Query<unknown, MerchantBackend.Instances.AccountKycRedirects> => ({ ): Query<unknown, MerchantBackend.KYC.AccountKycRedirects> => ({
method: "GET", method: "GET",
url: `http://backend/management/instances/${id}/kyc`, url: `http://backend/management/instances/${id}/kyc`,
}); });
@ -263,7 +263,7 @@ export const API_GET_CURRENT_INSTANCE: Query<
export const API_GET_CURRENT_INSTANCE_KYC: Query< export const API_GET_CURRENT_INSTANCE_KYC: Query<
unknown, unknown,
MerchantBackend.Instances.AccountKycRedirects MerchantBackend.KYC.AccountKycRedirects
> = { > = {
method: "GET", method: "GET",
url: `http://backend/instances/default/private/kyc`, url: `http://backend/instances/default/private/kyc`,

View File

@ -19,6 +19,9 @@ import {
Codec, Codec,
buildCodecForObject, buildCodecForObject,
codecForBoolean, codecForBoolean,
codecForConstString,
codecForEither,
codecForString,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
function parse_json_or_undefined<T>(str: string | undefined): T | undefined { function parse_json_or_undefined<T>(str: string | undefined): T | undefined {
@ -31,29 +34,49 @@ function parse_json_or_undefined<T>(str: string | undefined): T | undefined {
} }
export interface Settings { export interface Settings {
advanceOrderMode: boolean advanceOrderMode: boolean;
dateFormat: "ymd" | "dmy" | "mdy";
} }
const defaultSettings: Settings = { const defaultSettings: Settings = {
advanceOrderMode: false, advanceOrderMode: false,
dateFormat: "ymd",
} }
export const codecForSettings = (): Codec<Settings> => export const codecForSettings = (): Codec<Settings> =>
buildCodecForObject<Settings>() buildCodecForObject<Settings>()
.property("advanceOrderMode", codecForBoolean()) .property("advanceOrderMode", codecForBoolean())
.property("dateFormat", codecForEither(
codecForConstString("ymd"),
codecForConstString("dmy"),
codecForConstString("mdy"),
))
.build("Settings"); .build("Settings");
const SETTINGS_KEY = buildStorageKey("merchant-settings", codecForSettings()); const SETTINGS_KEY = buildStorageKey("merchant-settings", codecForSettings());
export function useSettings(): [ export function useSettings(): [
Readonly<Settings>, Readonly<Settings>,
<T extends keyof Settings>(key: T, value: Settings[T]) => void, (s: Settings) => void,
] { ] {
const { value, update } = useLocalStorage(SETTINGS_KEY); const { value, update } = useLocalStorage(SETTINGS_KEY, defaultSettings);
const parsed: Settings = value ?? defaultSettings; // const parsed: Settings = value ?? defaultSettings;
function updateField<T extends keyof Settings>(k: T, v: Settings[T]) { // function updateField<T extends keyof Settings>(k: T, v: Settings[T]) {
update({ ...parsed, [k]: v }); // const next = { ...parsed, [k]: v }
} // update(next);
return [parsed, updateField]; // }
return [value, update];
} }
export function dateFormatForSettings(s: Settings): string {
switch (s.dateFormat) {
case "ymd": return "yyyy/MM/dd"
case "dmy": return "dd/MM/yyyy"
case "mdy": return "MM/dd/yyyy"
}
}
export function datetimeFormatForSettings(s: Settings): string {
return dateFormatForSettings(s) + " HH:mm:ss"
}

View File

@ -19,7 +19,6 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { Amounts } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
@ -29,9 +28,8 @@ import {
FormProvider, FormProvider,
} from "../../../components/form/FormProvider.js"; } from "../../../components/form/FormProvider.js";
import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js";
import { SetTokenNewInstanceModal } from "../../../components/modal/index.js";
import { MerchantBackend } from "../../../declaration.js"; import { MerchantBackend } from "../../../declaration.js";
import { INSTANCE_ID_REGEX, PAYTO_REGEX } from "../../../utils/constants.js"; import { INSTANCE_ID_REGEX } from "../../../utils/constants.js";
import { undefinedIfEmpty } from "../../../utils/table.js"; import { undefinedIfEmpty } from "../../../utils/table.js";
export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage & { export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage & {
@ -47,19 +45,19 @@ interface Props {
function with_defaults(id?: string): Partial<Entity> { function with_defaults(id?: string): Partial<Entity> {
return { return {
id, id,
accounts: [], // accounts: [],
user_type: "business", user_type: "business",
use_stefan: false,
default_pay_delay: { d_us: 2 * 1000 * 60 * 60 * 1000 }, // two hours default_pay_delay: { d_us: 2 * 1000 * 60 * 60 * 1000 }, // two hours
default_wire_fee_amortization: 1,
default_wire_transfer_delay: { d_us: 1000 * 2 * 60 * 60 * 24 * 1000 }, // two days default_wire_transfer_delay: { d_us: 1000 * 2 * 60 * 60 * 24 * 1000 }, // two days
}; };
} }
export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
const [value, valueHandler] = useState(with_defaults(forceId)); const [value, valueHandler] = useState(with_defaults(forceId));
const [isTokenSet, updateIsTokenSet] = useState<boolean>(false); // const [isTokenSet, updateIsTokenSet] = useState<boolean>(false);
const [isTokenDialogActive, updateIsTokenDialogActive] = // const [isTokenDialogActive, updateIsTokenDialogActive] =
useState<boolean>(false); // useState<boolean>(false);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -67,42 +65,24 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
id: !value.id id: !value.id
? i18n.str`required` ? i18n.str`required`
: !INSTANCE_ID_REGEX.test(value.id) : !INSTANCE_ID_REGEX.test(value.id)
? i18n.str`is not valid` ? i18n.str`is not valid`
: undefined, : undefined,
name: !value.name ? i18n.str`required` : undefined, name: !value.name ? i18n.str`required` : undefined,
user_type: !value.user_type user_type: !value.user_type
? i18n.str`required` ? i18n.str`required`
: value.user_type !== "business" && value.user_type !== "individual" : value.user_type !== "business" && value.user_type !== "individual"
? i18n.str`should be business or individual` ? i18n.str`should be business or individual`
: undefined,
accounts:
!value.accounts || !value.accounts.length
? i18n.str`required`
: undefinedIfEmpty(
value.accounts.map((p) => {
return !PAYTO_REGEX.test(p.payto_uri)
? i18n.str`is not valid`
: undefined;
}),
),
default_max_deposit_fee: !value.default_max_deposit_fee
? i18n.str`required`
: !Amounts.parse(value.default_max_deposit_fee)
? i18n.str`invalid format`
: undefined,
default_max_wire_fee: !value.default_max_wire_fee
? i18n.str`required`
: !Amounts.parse(value.default_max_wire_fee)
? i18n.str`invalid format`
: undefined,
default_wire_fee_amortization:
value.default_wire_fee_amortization === undefined
? i18n.str`required`
: isNaN(value.default_wire_fee_amortization)
? i18n.str`is not a number`
: value.default_wire_fee_amortization < 1
? i18n.str`must be 1 or greater`
: undefined, : undefined,
// accounts:
// !value.accounts || !value.accounts.length
// ? i18n.str`required`
// : undefinedIfEmpty(
// value.accounts.map((p) => {
// return !PAYTO_REGEX.test(p.payto_uri)
// ? i18n.str`is not valid`
// : undefined;
// }),
// ),
default_pay_delay: !value.default_pay_delay default_pay_delay: !value.default_pay_delay
? i18n.str`required` ? i18n.str`required`
: undefined, : undefined,
@ -129,12 +109,12 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
const submit = (): Promise<void> => { const submit = (): Promise<void> => {
// use conversion instead of this // use conversion instead of this
const newToken = value.auth_token; // const newToken = value.auth_token;
value.auth_token = undefined; // value.auth_token = undefined;
value.auth = value.auth = { method: "external" }
newToken === null || newToken === undefined // newToken === null || newToken === undefined
? { method: "external" } // ? { method: "external" }
: { method: "token", token: `secret-token:${newToken}` }; // : { method: "token", token: `secret-token:${newToken}` };
if (!value.address) value.address = {}; if (!value.address) value.address = {};
if (!value.jurisdiction) value.jurisdiction = {}; if (!value.jurisdiction) value.jurisdiction = {};
// remove above use conversion // remove above use conversion
@ -142,16 +122,16 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
return onCreate(value as Entity); return onCreate(value as Entity);
}; };
function updateToken(token: string | null) { // function updateToken(token: string | null) {
valueHandler((old) => ({ // valueHandler((old) => ({
...old, // ...old,
auth_token: token === null ? undefined : token, // auth_token: token === null ? undefined : token,
})); // }));
} // }
return ( return (
<div> <div>
<div class="columns"> {/* <div class="columns">
<div class="column" /> <div class="column" />
<div class="column is-four-fifths"> <div class="column is-four-fifths">
{isTokenDialogActive && ( {isTokenDialogActive && (
@ -174,9 +154,9 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
)} )}
</div> </div>
<div class="column" /> <div class="column" />
</div> </div> */}
<section class="hero is-hero-bar"> {/* <section class="hero is-hero-bar">
<div class="hero-body"> <div class="hero-body">
<div class="level"> <div class="level">
<div class="level-item has-text-centered"> <div class="level-item has-text-centered">
@ -186,8 +166,8 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
!isTokenSet !isTokenSet
? "button is-danger has-tooltip-bottom" ? "button is-danger has-tooltip-bottom"
: !value.auth_token : !value.auth_token
? "button has-tooltip-bottom" ? "button has-tooltip-bottom"
: "button is-info has-tooltip-bottom" : "button is-info has-tooltip-bottom"
} }
data-tooltip={i18n.str`change authorization configuration`} data-tooltip={i18n.str`change authorization configuration`}
onClick={() => updateIsTokenDialogActive(true)} onClick={() => updateIsTokenDialogActive(true)}
@ -228,7 +208,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
</div> </div>
</div> </div>
</div> </div>
</section> </section> */}
<section class="section is-main-section"> <section class="section is-main-section">
<div class="columns"> <div class="columns">
@ -250,7 +230,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
)} )}
<AsyncButton <AsyncButton
onClick={submit} onClick={submit}
disabled={!isTokenSet || hasErrors} disabled={hasErrors}
data-tooltip={ data-tooltip={
hasErrors hasErrors
? i18n.str`Need to complete marked fields and choose authorization method` ? i18n.str`Need to complete marked fields and choose authorization method`

View File

@ -0,0 +1,28 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { h, VNode, FunctionalComponent } from "preact";
import { CreatePage as TestedComponent } from "./CreatePage.js";
export default {
title: "Pages/Accounts/Create",
component: TestedComponent,
};

View File

@ -0,0 +1,173 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
import {
FormErrors,
FormProvider,
} from "../../../../components/form/FormProvider.js";
import { Input } from "../../../../components/form/Input.js";
import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { MerchantBackend } from "../../../../declaration.js";
import { undefinedIfEmpty } from "../../../../utils/table.js";
type Entity = MerchantBackend.BankAccounts.AccountAddDetails & { repeatPassword: string };
interface Props {
onCreate: (d: Entity) => Promise<void>;
onBack?: () => void;
}
const accountAuthType = ["none", "basic"];
function isValidURL(s: string): boolean {
try {
const u = new URL(s)
return true;
} catch (e) {
return false;
}
}
export function CreatePage({ onCreate, onBack }: Props): VNode {
const { i18n } = useTranslationContext();
const [state, setState] = useState<Partial<Entity>>({});
const errors: FormErrors<Entity> = {
payto_uri: !state.payto_uri ? i18n.str`required` : undefined,
credit_facade_credentials: !state.credit_facade_credentials
? undefined
: undefinedIfEmpty({
username:
state.credit_facade_credentials.type === "basic" && !state.credit_facade_credentials.username
? i18n.str`required`
: undefined,
password:
state.credit_facade_credentials.type === "basic" && !state.credit_facade_credentials.password
? i18n.str`required`
: undefined,
}),
credit_facade_url: !state.credit_facade_url
? undefined
: !isValidURL(state.credit_facade_url) ? i18n.str`not valid url`
: undefined,
repeatPassword:
!state.credit_facade_credentials
? undefined
: state.credit_facade_credentials.type === "basic" && (!state.credit_facade_credentials.password || state.credit_facade_credentials.password !== state.repeatPassword)
? i18n.str`is not the same`
: undefined,
};
const hasErrors = Object.keys(errors).some(
(k) => (errors as any)[k] !== undefined,
);
const submitForm = () => {
if (hasErrors) return Promise.reject();
delete state.repeatPassword
return onCreate(state as any);
};
return (
<div>
<section class="section is-main-section">
<div class="columns">
<div class="column" />
<div class="column is-four-fifths">
<FormProvider
object={state}
valueHandler={setState}
errors={errors}
>
<InputPaytoForm<Entity>
name="payto_uri"
label={i18n.str`Account`}
/>
<Input<Entity>
name="credit_facade_url"
label={i18n.str`Account info URL`}
help="https://bank.com"
expand
tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`}
/>
<InputSelector
name="credit_facade_credentials.type"
label={i18n.str`Auth type`}
tooltip={i18n.str`Choose the authentication type for the account info URL`}
values={accountAuthType}
toStr={(str) => {
if (str === "none") return "Without authentication";
return "Username and password";
}}
/>
{state.credit_facade_credentials?.type === "basic" ? (
<Fragment>
<Input
name="credit_facade_credentials.username"
label={i18n.str`Username`}
tooltip={i18n.str`Username to access the account information.`}
/>
<Input
name="credit_facade_credentials.password"
inputType="password"
label={i18n.str`Password`}
tooltip={i18n.str`Password to access the account information.`}
/>
<Input
name="repeatPassword"
inputType="password"
label={i18n.str`Repeat password`}
/>
</Fragment>
) : undefined}
</FormProvider>
<div class="buttons is-right mt-5">
{onBack && (
<button class="button" onClick={onBack}>
<i18n.Translate>Cancel</i18n.Translate>
</button>
)}
<AsyncButton
disabled={hasErrors}
data-tooltip={
hasErrors
? i18n.str`Need to complete marked fields`
: "confirm operation"
}
onClick={submitForm}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</div>
</div>
<div class="column" />
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,65 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { NotificationCard } from "../../../../components/menu/index.js";
import { MerchantBackend } from "../../../../declaration.js";
import { useWebhookAPI } from "../../../../hooks/webhooks.js";
import { Notification } from "../../../../utils/types.js";
import { CreatePage } from "./CreatePage.js";
import { useOtpDeviceAPI } from "../../../../hooks/otp.js";
import { useBankAccountAPI } from "../../../../hooks/bank.js";
export type Entity = MerchantBackend.BankAccounts.AccountAddDetails;
interface Props {
onBack?: () => void;
onConfirm: () => void;
}
export default function CreateValidator({ onConfirm, onBack }: Props): VNode {
const { createBankAccount } = useBankAccountAPI();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
return (
<>
<NotificationCard notification={notif} />
<CreatePage
onBack={onBack}
onCreate={(request: Entity) => {
return createBankAccount(request)
.then((d) => {
onConfirm()
})
.catch((error) => {
setNotif({
message: i18n.str`could not create device`,
type: "ERROR",
description: error.message,
});
});
}}
/>
</>
);
}

View File

@ -0,0 +1,28 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { FunctionalComponent, h } from "preact";
import { ListPage as TestedComponent } from "./ListPage.js";
export default {
title: "Pages/Accounts/List",
component: TestedComponent,
};

View File

@ -0,0 +1,64 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { h, VNode } from "preact";
import { MerchantBackend } from "../../../../declaration.js";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { CardTable } from "./Table.js";
export interface Props {
devices: MerchantBackend.BankAccounts.BankAccountEntry[];
onLoadMoreBefore?: () => void;
onLoadMoreAfter?: () => void;
onCreate: () => void;
onDelete: (e: MerchantBackend.BankAccounts.BankAccountEntry) => void;
onSelect: (e: MerchantBackend.BankAccounts.BankAccountEntry) => void;
}
export function ListPage({
devices,
onCreate,
onDelete,
onSelect,
onLoadMoreBefore,
onLoadMoreAfter,
}: Props): VNode {
const form = { payto_uri: "" };
const { i18n } = useTranslationContext();
return (
<section class="section is-main-section">
<CardTable
accounts={devices.map((o) => ({
...o,
id: String(o.h_wire),
}))}
onCreate={onCreate}
onDelete={onDelete}
onSelect={onSelect}
onLoadMoreBefore={onLoadMoreBefore}
hasMoreBefore={!onLoadMoreBefore}
onLoadMoreAfter={onLoadMoreAfter}
hasMoreAfter={!onLoadMoreAfter}
/>
</section>
);
}

View File

@ -0,0 +1,385 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
import { StateUpdater, useState } from "preact/hooks";
import { MerchantBackend } from "../../../../declaration.js";
import { parsePaytoUri, PaytoType, PaytoUri, PaytoUriBitcoin, PaytoUriIBAN, PaytoUriTalerBank, PaytoUriUnknown } from "@gnu-taler/taler-util";
type Entity = MerchantBackend.BankAccounts.BankAccountEntry;
interface Props {
accounts: Entity[];
onDelete: (e: Entity) => void;
onSelect: (e: Entity) => void;
onCreate: () => void;
onLoadMoreBefore?: () => void;
hasMoreBefore?: boolean;
hasMoreAfter?: boolean;
onLoadMoreAfter?: () => void;
}
export function CardTable({
accounts,
onCreate,
onDelete,
onSelect,
onLoadMoreAfter,
onLoadMoreBefore,
hasMoreAfter,
hasMoreBefore,
}: Props): VNode {
const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
const { i18n } = useTranslationContext();
return (
<div class="card has-table">
<header class="card-header">
<p class="card-header-title">
<span class="icon">
<i class="mdi mdi-newspaper" />
</span>
<i18n.Translate>Bank accounts</i18n.Translate>
</p>
<div class="card-header-icon" aria-label="more options">
<span
class="has-tooltip-left"
data-tooltip={i18n.str`add new accounts`}
>
<button class="button is-info" type="button" onClick={onCreate}>
<span class="icon is-small">
<i class="mdi mdi-plus mdi-36px" />
</span>
</button>
</span>
</div>
</header>
<div class="card-content">
<div class="b-table has-pagination">
<div class="table-wrapper has-mobile-cards">
{accounts.length > 0 ? (
<Table
accounts={accounts}
onDelete={onDelete}
onSelect={onSelect}
rowSelection={rowSelection}
rowSelectionHandler={rowSelectionHandler}
onLoadMoreAfter={onLoadMoreAfter}
onLoadMoreBefore={onLoadMoreBefore}
hasMoreAfter={hasMoreAfter}
hasMoreBefore={hasMoreBefore}
/>
) : (
<EmptyTable />
)}
</div>
</div>
</div>
</div>
);
}
interface TableProps {
rowSelection: string[];
accounts: Entity[];
onDelete: (e: Entity) => void;
onSelect: (e: Entity) => void;
rowSelectionHandler: StateUpdater<string[]>;
onLoadMoreBefore?: () => void;
hasMoreBefore?: boolean;
hasMoreAfter?: boolean;
onLoadMoreAfter?: () => void;
}
function toggleSelected<T>(id: T): (prev: T[]) => T[] {
return (prev: T[]): T[] =>
prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id);
}
function Table({
accounts,
onLoadMoreAfter,
onDelete,
onSelect,
onLoadMoreBefore,
hasMoreAfter,
hasMoreBefore,
}: TableProps): VNode {
const { i18n } = useTranslationContext();
const emptyList: Record<PaytoType | "unknown", { parsed: PaytoUri, acc: Entity }[]> = { "bitcoin": [], "x-taler-bank": [], "iban": [], "unknown": [], }
const accountsByType = accounts.reduce((prev, acc) => {
const parsed = parsePaytoUri(acc.payto_uri)
if (!parsed) return prev //skip
if (parsed.targetType !== "bitcoin" && parsed.targetType !== "x-taler-bank" && parsed.targetType !== "iban") {
prev["unknown"].push({ parsed, acc })
} else {
prev[parsed.targetType].push({ parsed, acc })
}
return prev
}, emptyList)
const bitcoinAccounts = accountsByType["bitcoin"]
const talerbankAccounts = accountsByType["x-taler-bank"]
const ibanAccounts = accountsByType["iban"]
const unkownAccounts = accountsByType["unknown"]
return (
<Fragment>
{bitcoinAccounts.length > 0 && <div class="table-container">
<p class="card-header-title"><i18n.Translate>Bitcoin type accounts</i18n.Translate></p>
<table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>
<i18n.Translate>Address</i18n.Translate>
</th>
<th>
<i18n.Translate>Sewgit 1</i18n.Translate>
</th>
<th>
<i18n.Translate>Sewgit 2</i18n.Translate>
</th>
<th />
</tr>
</thead>
<tbody>
{bitcoinAccounts.map(({ parsed, acc }, idx) => {
const ac = parsed as PaytoUriBitcoin
return (
<tr key={idx}>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.targetPath}
</td>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.segwitAddrs[0]}
</td>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.segwitAddrs[1]}
</td>
<td class="is-actions-cell right-sticky">
<div class="buttons is-right">
<button
class="button is-danger is-small has-tooltip-left"
data-tooltip={i18n.str`delete selected accounts from the database`}
onClick={() => onDelete(acc)}
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>}
{talerbankAccounts.length > 0 && <div class="table-container">
<p class="card-header-title"><i18n.Translate>Taler type accounts</i18n.Translate></p>
<table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>
<i18n.Translate>Host</i18n.Translate>
</th>
<th>
<i18n.Translate>Account name</i18n.Translate>
</th>
<th />
</tr>
</thead>
<tbody>
{talerbankAccounts.map(({ parsed, acc }, idx) => {
const ac = parsed as PaytoUriTalerBank
return (
<tr key={idx}>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.host}
</td>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.account}
</td>
<td class="is-actions-cell right-sticky">
<div class="buttons is-right">
<button
class="button is-danger is-small has-tooltip-left"
data-tooltip={i18n.str`delete selected accounts from the database`}
onClick={() => onDelete(acc)}
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>}
{ibanAccounts.length > 0 && <div class="table-container">
<p class="card-header-title"><i18n.Translate>IBAN type accounts</i18n.Translate></p>
<table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>
<i18n.Translate>Account name</i18n.Translate>
</th>
<th>
<i18n.Translate>IBAN</i18n.Translate>
</th>
<th>
<i18n.Translate>BIC</i18n.Translate>
</th>
<th />
</tr>
</thead>
<tbody>
{ibanAccounts.map(({ parsed, acc }, idx) => {
const ac = parsed as PaytoUriIBAN
return (
<tr key={idx}>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.params["receiver-name"]}
</td>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.iban}
</td>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.bic ?? ""}
</td>
<td class="is-actions-cell right-sticky">
<div class="buttons is-right">
<button
class="button is-danger is-small has-tooltip-left"
data-tooltip={i18n.str`delete selected accounts from the database`}
onClick={() => onDelete(acc)}
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>}
{unkownAccounts.length > 0 && <div class="table-container">
<p class="card-header-title"><i18n.Translate>Other type accounts</i18n.Translate></p>
<table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>
<i18n.Translate>Type</i18n.Translate>
</th>
<th>
<i18n.Translate>Path</i18n.Translate>
</th>
<th />
</tr>
</thead>
<tbody>
{unkownAccounts.map(({ parsed, acc }, idx) => {
const ac = parsed as PaytoUriUnknown
return (
<tr key={idx}>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.targetType}
</td>
<td
onClick={(): void => onSelect(acc)}
style={{ cursor: "pointer" }}
>
{ac.targetPath}
</td>
<td class="is-actions-cell right-sticky">
<div class="buttons is-right">
<button
class="button is-danger is-small has-tooltip-left"
data-tooltip={i18n.str`delete selected accounts from the database`}
onClick={() => onDelete(acc)}
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>}
</Fragment>
);
}
function EmptyTable(): VNode {
const { i18n } = useTranslationContext();
return (
<div class="content has-text-grey has-text-centered">
<p>
<span class="icon is-large">
<i class="mdi mdi-emoticon-sad mdi-48px" />
</span>
</p>
<p>
<i18n.Translate>
There is no accounts yet, add more pressing the + sign
</i18n.Translate>
</p>
</div>
);
}

View File

@ -0,0 +1,107 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { HttpStatusCode } from "@gnu-taler/taler-util";
import {
ErrorType,
HttpError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { Loading } from "../../../../components/exception/loading.js";
import { NotificationCard } from "../../../../components/menu/index.js";
import { MerchantBackend } from "../../../../declaration.js";
import { useInstanceOtpDevices, useOtpDeviceAPI } from "../../../../hooks/otp.js";
import { Notification } from "../../../../utils/types.js";
import { ListPage } from "./ListPage.js";
import { useBankAccountAPI, useInstanceBankAccounts } from "../../../../hooks/bank.js";
interface Props {
onUnauthorized: () => VNode;
onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
onNotFound: () => VNode;
onCreate: () => void;
onSelect: (id: string) => void;
}
export default function ListValidators({
onUnauthorized,
onLoadError,
onCreate,
onSelect,
onNotFound,
}: Props): VNode {
const [position, setPosition] = useState<string | undefined>(undefined);
const { i18n } = useTranslationContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { deleteBankAccount } = useBankAccountAPI();
const result = useInstanceBankAccounts({ position }, (id) => setPosition(id));
if (result.loading) return <Loading />;
if (!result.ok) {
if (
result.type === ErrorType.CLIENT &&
result.status === HttpStatusCode.Unauthorized
)
return onUnauthorized();
if (
result.type === ErrorType.CLIENT &&
result.status === HttpStatusCode.NotFound
)
return onNotFound();
return onLoadError(result);
}
return (
<Fragment>
<NotificationCard notification={notif} />
<ListPage
devices={result.data.accounts}
onLoadMoreBefore={
result.isReachingStart ? result.loadMorePrev : undefined
}
onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined}
onCreate={onCreate}
onSelect={(e) => {
onSelect(e.h_wire);
}}
onDelete={(e: MerchantBackend.BankAccounts.BankAccountEntry) =>
deleteBankAccount(e.h_wire)
.then(() =>
setNotif({
message: i18n.str`bank account delete successfully`,
type: "SUCCESS",
}),
)
.catch((error) =>
setNotif({
message: i18n.str`could not delete the bank account`,
type: "ERROR",
description: error.message,
}),
)
}
/>
</Fragment>
);
}

View File

@ -0,0 +1,32 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { h, VNode, FunctionalComponent } from "preact";
import { UpdatePage as TestedComponent } from "./UpdatePage.js";
export default {
title: "Pages/Validators/Update",
component: TestedComponent,
argTypes: {
onUpdate: { action: "onUpdate" },
onBack: { action: "onBack" },
},
};

View File

@ -0,0 +1,114 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact";
import { useState } from "preact/hooks";
import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
import {
FormErrors,
FormProvider,
} from "../../../../components/form/FormProvider.js";
import { Input } from "../../../../components/form/Input.js";
import { MerchantBackend, WithId } from "../../../../declaration.js";
type Entity = MerchantBackend.BankAccounts.AccountPatchDetails & WithId;
interface Props {
onUpdate: (d: Entity) => Promise<void>;
onBack?: () => void;
account: Entity;
}
export function UpdatePage({ account, onUpdate, onBack }: Props): VNode {
const { i18n } = useTranslationContext();
const [state, setState] = useState<Partial<Entity>>(account);
const errors: FormErrors<Entity> = {
};
const hasErrors = Object.keys(errors).some(
(k) => (errors as any)[k] !== undefined,
);
const submitForm = () => {
if (hasErrors) return Promise.reject();
return onUpdate(state as any);
};
return (
<div>
<section class="section">
<section class="hero is-hero-bar">
<div class="hero-body">
<div class="level">
<div class="level-left">
<div class="level-item">
<span class="is-size-4">
Account: <b>{account.id}</b>
</span>
</div>
</div>
</div>
</div>
</section>
<hr />
<section class="section is-main-section">
<div class="columns">
<div class="column is-four-fifths">
<FormProvider
object={state}
valueHandler={setState}
errors={errors}
>
<Input<Entity>
name="credit_facade_url"
label={i18n.str`Description`}
tooltip={i18n.str`dddd`}
/>
</FormProvider>
<div class="buttons is-right mt-5">
{onBack && (
<button class="button" onClick={onBack}>
<i18n.Translate>Cancel</i18n.Translate>
</button>
)}
<AsyncButton
disabled={hasErrors}
data-tooltip={
hasErrors
? i18n.str`Need to complete marked fields`
: "confirm operation"
}
onClick={submitForm}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</div>
</div>
</div>
</section>
</section>
</div>
);
}

View File

@ -0,0 +1,96 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { HttpStatusCode } from "@gnu-taler/taler-util";
import {
ErrorType,
HttpError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { Loading } from "../../../../components/exception/loading.js";
import { NotificationCard } from "../../../../components/menu/index.js";
import { MerchantBackend, WithId } from "../../../../declaration.js";
import { useBankAccountAPI, useBankAccountDetails } from "../../../../hooks/bank.js";
import { Notification } from "../../../../utils/types.js";
import { UpdatePage } from "./UpdatePage.js";
export type Entity = MerchantBackend.BankAccounts.AccountPatchDetails & WithId;
interface Props {
onBack?: () => void;
onConfirm: () => void;
onUnauthorized: () => VNode;
onNotFound: () => VNode;
onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
bid: string;
}
export default function UpdateValidator({
bid,
onConfirm,
onBack,
onUnauthorized,
onNotFound,
onLoadError,
}: Props): VNode {
const { updateBankAccount } = useBankAccountAPI();
const result = useBankAccountDetails(bid);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
if (result.loading) return <Loading />;
if (!result.ok) {
if (
result.type === ErrorType.CLIENT &&
result.status === HttpStatusCode.Unauthorized
)
return onUnauthorized();
if (
result.type === ErrorType.CLIENT &&
result.status === HttpStatusCode.NotFound
)
return onNotFound();
return onLoadError(result);
}
return (
<Fragment>
<NotificationCard notification={notif} />
<UpdatePage
account={{ ...result.data, id: bid }}
onBack={onBack}
onUpdate={(data) => {
return updateBankAccount(bid, data)
.then(onConfirm)
.catch((error) => {
setNotif({
message: i18n.str`could not update account`,
type: "ERROR",
description: error.message,
});
});
}}
/>
</Fragment>
);
}

View File

@ -36,14 +36,13 @@ interface Props {
function convert( function convert(
from: MerchantBackend.Instances.QueryInstancesResponse, from: MerchantBackend.Instances.QueryInstancesResponse,
): Entity { ): Entity {
const { accounts: allAccounts, ...rest } = from;
const accounts = allAccounts.filter((a) => a.active);
const defaults = { const defaults = {
default_wire_fee_amortization: 1, default_wire_fee_amortization: 1,
use_stefan: true,
default_pay_delay: { d_us: 1000 * 60 * 60 * 1000 }, //one hour default_pay_delay: { d_us: 1000 * 60 * 60 * 1000 }, //one hour
default_wire_transfer_delay: { d_us: 1000 * 60 * 60 * 2 * 1000 }, //two hours default_wire_transfer_delay: { d_us: 1000 * 60 * 60 * 2 * 1000 }, //two hours
}; };
return { ...defaults, ...rest, accounts }; return { ...defaults, ...from };
} }
export function DetailPage({ selected }: Props): VNode { export function DetailPage({ selected }: Props): VNode {
@ -74,11 +73,6 @@ export function DetailPage({ selected }: Props): VNode {
<div class="column is-6"> <div class="column is-6">
<FormProvider<Entity> object={value} valueHandler={valueHandler}> <FormProvider<Entity> object={value} valueHandler={valueHandler}>
<Input<Entity> name="name" readonly label={i18n.str`Name`} /> <Input<Entity> name="name" readonly label={i18n.str`Name`} />
<Input<Entity>
name="accounts"
readonly
label={i18n.str`Account address`}
/>
</FormProvider> </FormProvider>
</div> </div>
<div class="column" /> <div class="column" />

View File

@ -51,17 +51,15 @@ function createExample<Props>(
export const Example = createExample(TestedComponent, { export const Example = createExample(TestedComponent, {
selected: { selected: {
accounts: [],
name: "name", name: "name",
auth: { method: "external" }, auth: { method: "external" },
address: {}, address: {},
user_type: "business",
jurisdiction: {}, jurisdiction: {},
default_max_deposit_fee: "TESTKUDOS:2", use_stefan: true,
default_max_wire_fee: "TESTKUDOS:1",
default_pay_delay: { default_pay_delay: {
d_us: 1000 * 1000, //one second d_us: 1000 * 1000, //one second
}, },
default_wire_fee_amortization: 1,
default_wire_transfer_delay: { default_wire_transfer_delay: {
d_us: 1000 * 1000, //one second d_us: 1000 * 1000, //one second
}, },

View File

@ -54,5 +54,5 @@ export const Example = tests.createExample(TestedComponent, {
payto_uri: "payto://iban/de123123123", payto_uri: "payto://iban/de123123123",
}, },
], ],
} as MerchantBackend.Instances.AccountKycRedirects, } as MerchantBackend.KYC.AccountKycRedirects,
}); });

View File

@ -24,7 +24,7 @@ import { h, VNode } from "preact";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
export interface Props { export interface Props {
status: MerchantBackend.Instances.AccountKycRedirects; status: MerchantBackend.KYC.AccountKycRedirects;
} }
export function ListPage({ status }: Props): VNode { export function ListPage({ status }: Props): VNode {
@ -85,11 +85,11 @@ export function ListPage({ status }: Props): VNode {
); );
} }
interface PendingTableProps { interface PendingTableProps {
entries: MerchantBackend.Instances.MerchantAccountKycRedirect[]; entries: MerchantBackend.KYC.MerchantAccountKycRedirect[];
} }
interface TimedOutTableProps { interface TimedOutTableProps {
entries: MerchantBackend.Instances.ExchangeKycTimeout[]; entries: MerchantBackend.KYC.ExchangeKycTimeout[];
} }
function PendingTable({ entries }: PendingTableProps): VNode { function PendingTable({ entries }: PendingTableProps): VNode {

View File

@ -42,12 +42,13 @@ function createExample<Props>(
export const Example = createExample(TestedComponent, { export const Example = createExample(TestedComponent, {
instanceConfig: { instanceConfig: {
default_max_deposit_fee: "",
default_max_wire_fee: "",
default_pay_delay: { default_pay_delay: {
d_us: 1000 * 1000 * 60 * 60, //one hour d_us: 1000 * 1000 * 60 * 60, //one hour
}, },
default_wire_fee_amortization: 1, default_wire_transfer_delay: {
d_us: 1000 * 1000 * 60 * 60, //one hour
},
use_stefan: true,
}, },
instanceInventory: [ instanceInventory: [
{ {

View File

@ -44,6 +44,7 @@ import { OrderCreateSchema as schema } from "../../../../schemas/index.js";
import { rate } from "../../../../utils/amount.js"; import { rate } from "../../../../utils/amount.js";
import { undefinedIfEmpty } from "../../../../utils/table.js"; import { undefinedIfEmpty } from "../../../../utils/table.js";
import { useSettings } from "../../../../hooks/useSettings.js"; import { useSettings } from "../../../../hooks/useSettings.js";
import { InputToggle } from "../../../../components/form/InputToggle.js";
interface Props { interface Props {
onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void;
@ -52,34 +53,38 @@ interface Props {
instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[]; instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[];
} }
interface InstanceConfig { interface InstanceConfig {
default_max_wire_fee: string; use_stefan: boolean;
default_max_deposit_fee: string;
default_wire_fee_amortization: number;
default_pay_delay: Duration; default_pay_delay: Duration;
default_wire_transfer_delay: Duration;
} }
function with_defaults(config: InstanceConfig): Partial<Entity> { function with_defaults(config: InstanceConfig, currency: string): Partial<Entity> {
const defaultPayDeadline = const defaultPayDeadline =
!config.default_pay_delay || config.default_pay_delay.d_us === "forever" !config.default_pay_delay || config.default_pay_delay.d_us === "forever"
? undefined ? undefined
: add(new Date(), { : add(new Date(), {
seconds: config.default_pay_delay.d_us / (1000 * 1000), seconds: config.default_pay_delay.d_us / (1000 * 1000),
}); });
const defaultWireDeadline =
!config.default_wire_transfer_delay || config.default_wire_transfer_delay.d_us === "forever"
? undefined
: add(new Date(), {
seconds: config.default_wire_transfer_delay.d_us / (1000 * 1000),
});
return { return {
inventoryProducts: {}, inventoryProducts: {},
products: [], products: [],
pricing: {}, pricing: {},
payments: { payments: {
max_wire_fee: config.default_max_wire_fee, max_fee: undefined,
max_fee: config.default_max_deposit_fee,
wire_fee_amortization: config.default_wire_fee_amortization,
pay_deadline: defaultPayDeadline, pay_deadline: defaultPayDeadline,
refund_deadline: defaultPayDeadline, refund_deadline: defaultPayDeadline,
createToken: true, createToken: true,
wire_transfer_deadline: defaultWireDeadline,
}, },
shipping: {}, shipping: {},
extra: "", extra: {},
}; };
} }
@ -107,8 +112,6 @@ interface Payments {
wire_transfer_deadline?: Date; wire_transfer_deadline?: Date;
auto_refund_deadline?: Date; auto_refund_deadline?: Date;
max_fee?: string; max_fee?: string;
max_wire_fee?: string;
wire_fee_amortization?: number;
createToken: boolean; createToken: boolean;
minimum_age?: number; minimum_age?: number;
} }
@ -118,7 +121,7 @@ interface Entity {
pricing: Partial<Pricing>; pricing: Partial<Pricing>;
payments: Partial<Payments>; payments: Partial<Payments>;
shipping: Partial<Shipping>; shipping: Partial<Shipping>;
extra: string; extra: Record<string, string>;
} }
const stringIsValidJSON = (value: string) => { const stringIsValidJSON = (value: string) => {
@ -136,8 +139,9 @@ export function CreatePage({
instanceConfig, instanceConfig,
instanceInventory, instanceInventory,
}: Props): VNode { }: Props): VNode {
const [value, valueHandler] = useState(with_defaults(instanceConfig));
const config = useConfigContext(); const config = useConfigContext();
const instance_default = with_defaults(instanceConfig, config.currency)
const [value, valueHandler] = useState(instance_default);
const zero = Amounts.zeroOfCurrency(config.currency); const zero = Amounts.zeroOfCurrency(config.currency);
const [settings] = useSettings() const [settings] = useSettings()
const inventoryList = Object.values(value.inventoryProducts || {}); const inventoryList = Object.values(value.inventoryProducts || {});
@ -160,10 +164,10 @@ export function CreatePage({
? i18n.str`must be greater than 0` ? i18n.str`must be greater than 0`
: undefined, : undefined,
}), }),
extra: // extra:
value.extra && !stringIsValidJSON(value.extra) // value.extra && !stringIsValidJSON(value.extra)
? i18n.str`not a valid json` // ? i18n.str`not a valid json`
: undefined, // : undefined,
payments: undefinedIfEmpty({ payments: undefinedIfEmpty({
refund_deadline: !value.payments?.refund_deadline refund_deadline: !value.payments?.refund_deadline
? undefined ? undefined
@ -202,6 +206,7 @@ export function CreatePage({
) )
? i18n.str`auto refund cannot be after refund deadline` ? i18n.str`auto refund cannot be after refund deadline`
: undefined, : undefined,
}), }),
shipping: undefinedIfEmpty({ shipping: undefinedIfEmpty({
delivery_date: !value.shipping?.delivery_date delivery_date: !value.shipping?.delivery_date
@ -225,7 +230,7 @@ export function CreatePage({
amount: order.pricing.order_price, amount: order.pricing.order_price,
summary: order.pricing.summary, summary: order.pricing.summary,
products: productList, products: productList,
extra: value.extra, extra: JSON.stringify(value.extra),
pay_deadline: value.payments.pay_deadline pay_deadline: value.payments.pay_deadline
? { ? {
t_s: Math.floor(value.payments.pay_deadline.getTime() / 1000), t_s: Math.floor(value.payments.pay_deadline.getTime() / 1000),
@ -250,9 +255,7 @@ export function CreatePage({
), ),
} }
: undefined, : undefined,
wire_fee_amortization: value.payments.wire_fee_amortization as number,
max_fee: value.payments.max_fee as string, max_fee: value.payments.max_fee as string,
max_wire_fee: value.payments.max_wire_fee as string,
delivery_date: value.shipping.delivery_date delivery_date: value.shipping.delivery_date
? { t_s: value.shipping.delivery_date.getTime() / 1000 } ? { t_s: value.shipping.delivery_date.getTime() / 1000 }
@ -326,6 +329,8 @@ export function CreatePage({
const totalAsString = Amounts.stringify(totalPrice.amount); const totalAsString = Amounts.stringify(totalPrice.amount);
const allProducts = productList.concat(inventoryList.map(asProduct)); const allProducts = productList.concat(inventoryList.map(asProduct));
const [newField, setNewField] = useState("")
useEffect(() => { useEffect(() => {
valueHandler((v) => { valueHandler((v) => {
return { return {
@ -486,16 +491,61 @@ export function CreatePage({
name="payments.pay_deadline" name="payments.pay_deadline"
label={i18n.str`Payment deadline`} label={i18n.str`Payment deadline`}
tooltip={i18n.str`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`} tooltip={i18n.str`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`}
side={
<span>
<button class="button" onClick={() => {
valueHandler({
...value,
payments: {
...(value.payments ?? {}),
pay_deadline: instance_default.payments?.pay_deadline
}
})
}}>
<i18n.Translate>default</i18n.Translate>
</button>
</span>
}
/> />
<InputDate <InputDate
name="payments.refund_deadline" name="payments.refund_deadline"
label={i18n.str`Refund deadline`} label={i18n.str`Refund deadline`}
tooltip={i18n.str`Time until which the order can be refunded by the merchant.`} tooltip={i18n.str`Time until which the order can be refunded by the merchant.`}
side={
<span>
<button class="button" onClick={() => {
valueHandler({
...value,
payments: {
...(value.payments ?? {}),
refund_deadline: instance_default.payments?.refund_deadline
}
})
}}>
<i18n.Translate>default</i18n.Translate>
</button>
</span>
}
/> />
<InputDate <InputDate
name="payments.wire_transfer_deadline" name="payments.wire_transfer_deadline"
label={i18n.str`Wire transfer deadline`} label={i18n.str`Wire transfer deadline`}
tooltip={i18n.str`Deadline for the exchange to make the wire transfer.`} tooltip={i18n.str`Deadline for the exchange to make the wire transfer.`}
side={
<span>
<button class="button" onClick={() => {
valueHandler({
...value,
payments: {
...(value.payments ?? {}),
wire_transfer_deadline: instance_default.payments?.wire_transfer_deadline
}
})
}}>
<i18n.Translate>default</i18n.Translate>
</button>
</span>
}
/> />
<InputDate <InputDate
name="payments.auto_refund_deadline" name="payments.auto_refund_deadline"
@ -505,23 +555,13 @@ export function CreatePage({
<InputCurrency <InputCurrency
name="payments.max_fee" name="payments.max_fee"
label={i18n.str`Maximum deposit fee`} label={i18n.str`Maximum fee`}
tooltip={i18n.str`Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} tooltip={i18n.str`Maximum fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`}
/> />
<InputCurrency <InputToggle
name="payments.max_wire_fee"
label={i18n.str`Maximum wire fee`}
tooltip={i18n.str`Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.`}
/>
<InputNumber
name="payments.wire_fee_amortization"
label={i18n.str`Wire fee amortization`}
tooltip={i18n.str`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`}
/>
<InputBoolean
name="payments.createToken" name="payments.createToken"
label={i18n.str`Create token`} label={i18n.str`Create token`}
tooltip={i18n.str`Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.`} tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`}
/> />
<InputNumber <InputNumber
name="payments.minimum_age" name="payments.minimum_age"
@ -530,7 +570,7 @@ export function CreatePage({
help={ help={
minAgeByProducts > 0 minAgeByProducts > 0
? i18n.str`Min age defined by the producs is ${minAgeByProducts}` ? i18n.str`Min age defined by the producs is ${minAgeByProducts}`
: undefined : i18n.str`No product with age restriction in this order`
} }
/> />
</InputGroup> </InputGroup>
@ -542,12 +582,53 @@ export function CreatePage({
label={i18n.str`Additional information`} label={i18n.str`Additional information`}
tooltip={i18n.str`Custom information to be included in the contract for this order.`} tooltip={i18n.str`Custom information to be included in the contract for this order.`}
> >
<Input {Object.keys(value.extra ?? {}).map((key) => {
name="extra"
inputType="multiline" return <Input
label={`Value`} name={`extra.${key}`}
tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`} inputType="multiline"
/> label={key}
tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`}
side={
<button class="button" onClick={(e) => {
if (value.extra && value.extra[key] !== undefined) {
console.log(value.extra)
delete value.extra[key]
}
valueHandler({
...value,
})
}}>remove</button>
}
/>
})}
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">
<i18n.Translate>Custom field name</i18n.Translate>
<span class="icon has-tooltip-right" data-tooltip={"new extra field"}>
<i class="mdi mdi-information" />
</span>
</label>
</div>
<div class="field-body is-flex-grow-3">
<div class="field">
<p class="control">
<input class="input " value={newField} onChange={(e) => setNewField(e.currentTarget.value)} />
</p>
</div>
</div>
<button class="button" onClick={(e) => {
setNewField("")
valueHandler({
...value,
extra: {
...(value.extra ?? {}),
[newField]: ""
}
})
}}>add</button>
</div>
</InputGroup> </InputGroup>
} }
</FormProvider> </FormProvider>

View File

@ -38,7 +38,7 @@ export type Entity = {
}; };
interface Props { interface Props {
onBack?: () => void; onBack?: () => void;
onConfirm: () => void; onConfirm: (id: string) => void;
onUnauthorized: () => VNode; onUnauthorized: () => VNode;
onNotFound: () => VNode; onNotFound: () => VNode;
onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
@ -95,7 +95,9 @@ export default function OrderCreate({
onBack={onBack} onBack={onBack}
onCreate={(request: MerchantBackend.Orders.PostOrderRequest) => { onCreate={(request: MerchantBackend.Orders.PostOrderRequest) => {
createOrder(request) createOrder(request)
.then(onConfirm) .then((r) => {
return onConfirm(r.data.order_id)
})
.catch((error) => { .catch((error) => {
setNotif({ setNotif({
message: "could not create order", message: "could not create order",

View File

@ -50,13 +50,11 @@ const defaultContractTerm = {
auditors: [], auditors: [],
exchanges: [], exchanges: [],
max_fee: "TESTKUDOS:1", max_fee: "TESTKUDOS:1",
max_wire_fee: "TESTKUDOS:1",
merchant: {} as any, merchant: {} as any,
merchant_base_url: "http://merchant.url/", merchant_base_url: "http://merchant.url/",
order_id: "2021.165-03GDFC26Y1NNG", order_id: "2021.165-03GDFC26Y1NNG",
products: [], products: [],
summary: "text summary", summary: "text summary",
wire_fee_amortization: 1,
wire_transfer_deadline: { wire_transfer_deadline: {
t_s: "never", t_s: "never",
}, },

View File

@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { AmountJson, Amounts } from "@gnu-taler/taler-util"; import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { format, formatDistance } from "date-fns"; import { format, formatDistance } from "date-fns";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
@ -38,6 +38,7 @@ import { MerchantBackend } from "../../../../declaration.js";
import { mergeRefunds } from "../../../../utils/amount.js"; import { mergeRefunds } from "../../../../utils/amount.js";
import { RefundModal } from "../list/Table.js"; import { RefundModal } from "../list/Table.js";
import { Event, Timeline } from "./Timeline.js"; import { Event, Timeline } from "./Timeline.js";
import { dateFormatForSettings, datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse;
type CT = MerchantBackend.ContractTerms; type CT = MerchantBackend.ContractTerms;
@ -87,18 +88,6 @@ function ContractTerms({ value }: { value: CT }) {
label={i18n.str`Max fee`} label={i18n.str`Max fee`}
tooltip={i18n.str`maximum total deposit fee accepted by the merchant for this contract`} tooltip={i18n.str`maximum total deposit fee accepted by the merchant for this contract`}
/> />
<Input<CT>
readonly
name="max_wire_fee"
label={i18n.str`Max wire fee`}
tooltip={i18n.str`maximum wire fee accepted by the merchant`}
/>
<Input<CT>
readonly
name="wire_fee_amortization"
label={i18n.str`Wire fee amortization`}
tooltip={i18n.str`over how many customer transactions does the merchant expect to amortize wire fees on average`}
/>
<InputDate<CT> <InputDate<CT>
readonly readonly
name="timestamp" name="timestamp"
@ -204,6 +193,7 @@ function ClaimedPage({
const [value, valueHandler] = useState<Partial<Claimed>>(order); const [value, valueHandler] = useState<Partial<Claimed>>(order);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings()
return ( return (
<div> <div>
@ -249,7 +239,7 @@ function ClaimedPage({
</b>{" "} </b>{" "}
{format( {format(
new Date(order.contract_terms.timestamp.t_s * 1000), new Date(order.contract_terms.timestamp.t_s * 1000),
"yyyy-MM-dd HH:mm:ss", datetimeFormatForSettings(settings)
)} )}
</p> </p>
</div> </div>
@ -427,9 +417,10 @@ function PaidPage({
const [value, valueHandler] = useState<Partial<Paid>>(order); const [value, valueHandler] = useState<Partial<Paid>>(order);
const { url } = useBackendContext(); const { url } = useBackendContext();
const refundHost = url.replace(/.*:\/\//, ""); // remove protocol part const refundurl = stringifyRefundUri({
const proto = url.startsWith("http://") ? "taler+http" : "taler"; merchantBaseUrl: url,
const refundurl = `${proto}://refund/${refundHost}/${order.contract_terms.order_id}/`; orderId: order.contract_terms.order_id
})
const refundable = const refundable =
new Date().getTime() < order.contract_terms.refund_deadline.t_s * 1000; new Date().getTime() < order.contract_terms.refund_deadline.t_s * 1000;
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -618,6 +609,7 @@ function UnpaidPage({
}) { }) {
const [value, valueHandler] = useState<Partial<Unpaid>>(order); const [value, valueHandler] = useState<Partial<Unpaid>>(order);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings()
return ( return (
<div> <div>
<section class="hero is-hero-bar"> <section class="hero is-hero-bar">
@ -666,7 +658,7 @@ function UnpaidPage({
? "never" ? "never"
: format( : format(
new Date(order.creation_time.t_s * 1000), new Date(order.creation_time.t_s * 1000),
"yyyy-MM-dd HH:mm:ss", datetimeFormatForSettings(settings)
)} )}
</p> </p>
</div> </div>

View File

@ -16,6 +16,7 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { h } from "preact"; import { h } from "preact";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
interface Props { interface Props {
events: Event[]; events: Event[];
@ -30,7 +31,7 @@ export function Timeline({ events: e }: Props) {
}); });
events.sort((a, b) => a.when.getTime() - b.when.getTime()); events.sort((a, b) => a.when.getTime() - b.when.getTime());
const [settings] = useSettings();
const [state, setState] = useState(events); const [state, setState] = useState(events);
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
@ -104,7 +105,7 @@ export function Timeline({ events: e }: Props) {
} }
})()} })()}
<div class="timeline-content"> <div class="timeline-content">
{e.description !== "now" && <p class="heading">{format(e.when, "yyyy/MM/dd HH:mm:ss")}</p>} {e.description !== "now" && <p class="heading">{format(e.when, datetimeFormatForSettings(settings))}</p>}
<p>{e.description}</p> <p>{e.description}</p>
</div> </div>
</div> </div>

View File

@ -26,19 +26,24 @@ import { useState } from "preact/hooks";
import { DatePicker } from "../../../../components/picker/DatePicker.js"; import { DatePicker } from "../../../../components/picker/DatePicker.js";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import { CardTable } from "./Table.js"; import { CardTable } from "./Table.js";
import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
export interface ListPageProps { export interface ListPageProps {
errorOrderId: string | undefined; errorOrderId: string | undefined;
onShowAll: () => void; onShowAll: () => void;
onShowNotPaid: () => void;
onShowPaid: () => void; onShowPaid: () => void;
onShowRefunded: () => void; onShowRefunded: () => void;
onShowNotWired: () => void; onShowNotWired: () => void;
onShowWired: () => void;
onCopyURL: (id: string) => void; onCopyURL: (id: string) => void;
isAllActive: string; isAllActive: string;
isPaidActive: string; isPaidActive: string;
isNotPaidActive: string;
isRefundedActive: string; isRefundedActive: string;
isNotWiredActive: string; isNotWiredActive: string;
isWiredActive: string;
jumpToDate?: Date; jumpToDate?: Date;
onSelectDate: (date?: Date) => void; onSelectDate: (date?: Date) => void;
@ -66,18 +71,23 @@ export function ListPage({
onCopyURL, onCopyURL,
onShowAll, onShowAll,
onShowPaid, onShowPaid,
onShowNotPaid,
onShowRefunded, onShowRefunded,
onShowNotWired, onShowNotWired,
onShowWired,
onSelectDate, onSelectDate,
isPaidActive, isPaidActive,
isRefundedActive, isRefundedActive,
isNotWiredActive, isNotWiredActive,
onCreate, onCreate,
isNotPaidActive,
isWiredActive,
}: ListPageProps): VNode { }: ListPageProps): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const dateTooltip = i18n.str`select date to show nearby orders`; const dateTooltip = i18n.str`select date to show nearby orders`;
const [pickDate, setPickDate] = useState(false); const [pickDate, setPickDate] = useState(false);
const [orderId, setOrderId] = useState<string>(""); const [orderId, setOrderId] = useState<string>("");
const [settings] = useSettings();
return ( return (
<section class="section is-main-section"> <section class="section is-main-section">
@ -116,13 +126,13 @@ export function ListPage({
<div class="column is-two-thirds"> <div class="column is-two-thirds">
<div class="tabs" style={{ overflow: "inherit" }}> <div class="tabs" style={{ overflow: "inherit" }}>
<ul> <ul>
<li class={isAllActive}> <li class={isNotPaidActive}>
<div <div
class="has-tooltip-right" class="has-tooltip-right"
data-tooltip={i18n.str`remove all filters`} data-tooltip={i18n.str`only show paid orders`}
> >
<a onClick={onShowAll}> <a onClick={onShowNotPaid}>
<i18n.Translate>All</i18n.Translate> <i18n.Translate>New</i18n.Translate>
</a> </a>
</div> </div>
</li> </li>
@ -156,6 +166,26 @@ export function ListPage({
</a> </a>
</div> </div>
</li> </li>
<li class={isWiredActive}>
<div
class="has-tooltip-left"
data-tooltip={i18n.str`only show orders where customers paid, but wire payments from payment provider are still pending`}
>
<a onClick={onShowWired}>
<i18n.Translate>Completed</i18n.Translate>
</a>
</div>
</li>
<li class={isAllActive}>
<div
class="has-tooltip-right"
data-tooltip={i18n.str`remove all filters`}
>
<a onClick={onShowAll}>
<i18n.Translate>All</i18n.Translate>
</a>
</div>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -180,8 +210,8 @@ export function ListPage({
class="input" class="input"
type="text" type="text"
readonly readonly
value={!jumpToDate ? "" : format(jumpToDate, "yyyy/MM/dd")} value={!jumpToDate ? "" : format(jumpToDate, dateFormatForSettings(settings))}
placeholder={i18n.str`date (YYYY/MM/DD)`} placeholder={i18n.str`date (${dateFormatForSettings(settings)})`}
onClick={() => { onClick={() => {
setPickDate(true); setPickDate(true);
}} }}

View File

@ -36,6 +36,7 @@ import { ConfirmModal } from "../../../../components/modal/index.js";
import { useConfigContext } from "../../../../context/config.js"; import { useConfigContext } from "../../../../context/config.js";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import { mergeRefunds } from "../../../../utils/amount.js"; import { mergeRefunds } from "../../../../utils/amount.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Orders.OrderHistoryEntry & WithId; type Entity = MerchantBackend.Orders.OrderHistoryEntry & WithId;
interface Props { interface Props {
@ -136,6 +137,7 @@ function Table({
hasMoreBefore, hasMoreBefore,
}: TableProps): VNode { }: TableProps): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings();
return ( return (
<div class="table-container"> <div class="table-container">
{onLoadMoreBefore && ( {onLoadMoreBefore && (
@ -173,9 +175,9 @@ function Table({
{i.timestamp.t_s === "never" {i.timestamp.t_s === "never"
? "never" ? "never"
: format( : format(
new Date(i.timestamp.t_s * 1000), new Date(i.timestamp.t_s * 1000),
"yyyy/MM/dd HH:mm:ss", datetimeFormatForSettings(settings),
)} )}
</td> </td>
<td <td
onClick={(): void => onSelect(i)} onClick={(): void => onSelect(i)}
@ -260,6 +262,7 @@ export function RefundModal({
}: RefundModalProps): VNode { }: RefundModalProps): VNode {
type State = { mainReason?: string; description?: string; refund?: string }; type State = { mainReason?: string; description?: string; refund?: string };
const [form, setValue] = useState<State>({}); const [form, setValue] = useState<State>({});
const [settings] = useSettings();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
// const [errors, setErrors] = useState<FormErrors<State>>({}); // const [errors, setErrors] = useState<FormErrors<State>>({});
@ -281,8 +284,8 @@ export function RefundModal({
const totalRefundable = !orderPrice const totalRefundable = !orderPrice
? Amounts.zeroOfCurrency(totalRefunded.currency) ? Amounts.zeroOfCurrency(totalRefunded.currency)
: refunds.length : refunds.length
? Amounts.sub(orderPrice, totalRefunded).amount ? Amounts.sub(orderPrice, totalRefunded).amount
: orderPrice; : orderPrice;
const isRefundable = Amounts.isNonZero(totalRefundable); const isRefundable = Amounts.isNonZero(totalRefundable);
const duplicatedText = i18n.str`duplicated`; const duplicatedText = i18n.str`duplicated`;
@ -296,10 +299,10 @@ export function RefundModal({
refund: !form.refund refund: !form.refund
? i18n.str`required` ? i18n.str`required`
: !Amounts.parse(form.refund) : !Amounts.parse(form.refund)
? i18n.str`invalid format` ? i18n.str`invalid format`
: Amounts.cmp(totalRefundable, Amounts.parse(form.refund)!) === -1 : Amounts.cmp(totalRefundable, Amounts.parse(form.refund)!) === -1
? i18n.str`this value exceed the refundable amount` ? i18n.str`this value exceed the refundable amount`
: undefined, : undefined,
}; };
const hasErrors = Object.keys(errors).some( const hasErrors = Object.keys(errors).some(
(k) => (errors as any)[k] !== undefined, (k) => (errors as any)[k] !== undefined,
@ -361,9 +364,9 @@ export function RefundModal({
{r.timestamp.t_s === "never" {r.timestamp.t_s === "never"
? "never" ? "never"
: format( : format(
new Date(r.timestamp.t_s * 1000), new Date(r.timestamp.t_s * 1000),
"yyyy-MM-dd HH:mm:ss", datetimeFormatForSettings(settings),
)} )}
</td> </td>
<td>{r.amount}</td> <td>{r.amount}</td>
<td>{r.reason}</td> <td>{r.reason}</td>

View File

@ -55,7 +55,7 @@ export default function OrderList({
onSelect, onSelect,
onNotFound, onNotFound,
}: Props): VNode { }: Props): VNode {
const [filter, setFilter] = useState<InstanceOrderFilter>({}); const [filter, setFilter] = useState<InstanceOrderFilter>({ paid: "no" });
const [orderToBeRefunded, setOrderToBeRefunded] = useState< const [orderToBeRefunded, setOrderToBeRefunded] = useState<
MerchantBackend.Orders.OrderHistoryEntry | undefined MerchantBackend.Orders.OrderHistoryEntry | undefined
>(undefined); >(undefined);
@ -88,13 +88,15 @@ export default function OrderList({
return onLoadError(result); return onLoadError(result);
} }
const isPaidActive = filter.paid === "yes" ? "is-active" : ""; const isNotPaidActive = filter.paid === "no" ? "is-active" : "";
const isPaidActive = filter.paid === "yes" && filter.wired === undefined ? "is-active" : "";
const isRefundedActive = filter.refunded === "yes" ? "is-active" : ""; const isRefundedActive = filter.refunded === "yes" ? "is-active" : "";
const isNotWiredActive = filter.wired === "no" ? "is-active" : ""; const isNotWiredActive = filter.wired === "no" && filter.paid === "yes" ? "is-active" : "";
const isWiredActive = filter.wired === "yes" ? "is-active" : "";
const isAllActive = const isAllActive =
filter.paid === undefined && filter.paid === undefined &&
filter.refunded === undefined && filter.refunded === undefined &&
filter.wired === undefined filter.wired === undefined
? "is-active" ? "is-active"
: ""; : "";
@ -127,7 +129,9 @@ export default function OrderList({
errorOrderId={errorOrderId} errorOrderId={errorOrderId}
isAllActive={isAllActive} isAllActive={isAllActive}
isNotWiredActive={isNotWiredActive} isNotWiredActive={isNotWiredActive}
isWiredActive={isWiredActive}
isPaidActive={isPaidActive} isPaidActive={isPaidActive}
isNotPaidActive={isNotPaidActive}
isRefundedActive={isRefundedActive} isRefundedActive={isRefundedActive}
jumpToDate={filter.date} jumpToDate={filter.date}
onCopyURL={(id) => onCopyURL={(id) =>
@ -137,9 +141,11 @@ export default function OrderList({
onSearchOrderById={testIfOrderExistAndSelect} onSearchOrderById={testIfOrderExistAndSelect}
onSelectDate={setNewDate} onSelectDate={setNewDate}
onShowAll={() => setFilter({})} onShowAll={() => setFilter({})}
onShowNotPaid={() => setFilter({ paid: "no" })}
onShowPaid={() => setFilter({ paid: "yes" })} onShowPaid={() => setFilter({ paid: "yes" })}
onShowRefunded={() => setFilter({ refunded: "yes" })} onShowRefunded={() => setFilter({ refunded: "yes" })}
onShowNotWired={() => setFilter({ wired: "no" })} onShowNotWired={() => setFilter({ wired: "no", paid: "yes" })}
onShowWired={() => setFilter({ wired: "yes" })}
/> />
{orderToBeRefunded && ( {orderToBeRefunded && (

View File

@ -32,6 +32,7 @@ import {
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputNumber } from "../../../../components/form/InputNumber.js";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Products.ProductDetail & WithId; type Entity = MerchantBackend.Products.ProductDetail & WithId;
@ -122,6 +123,7 @@ function Table({
onDelete, onDelete,
}: TableProps): VNode { }: TableProps): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings();
return ( return (
<div class="table-container"> <div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
@ -134,7 +136,7 @@ function Table({
<i18n.Translate>Description</i18n.Translate> <i18n.Translate>Description</i18n.Translate>
</th> </th>
<th> <th>
<i18n.Translate>Sell</i18n.Translate> <i18n.Translate>Price per unit</i18n.Translate>
</th> </th>
<th> <th>
<i18n.Translate>Taxes</i18n.Translate> <i18n.Translate>Taxes</i18n.Translate>
@ -156,10 +158,10 @@ function Table({
const restStockInfo = !i.next_restock const restStockInfo = !i.next_restock
? "" ? ""
: i.next_restock.t_s === "never" : i.next_restock.t_s === "never"
? "never" ? "never"
: `restock at ${format( : `restock at ${format(
new Date(i.next_restock.t_s * 1000), new Date(i.next_restock.t_s * 1000),
"yyyy/MM/dd", dateFormatForSettings(settings),
)}`; )}`;
let stockInfo: ComponentChildren = ""; let stockInfo: ComponentChildren = "";
if (i.total_stock < 0) { if (i.total_stock < 0) {
@ -332,26 +334,35 @@ function FastProductWithInfiniteStockUpdateForm({
/> />
</FormProvider> </FormProvider>
<div class="buttons is-right mt-5"> <div class="buttons is-expanded">
<button class="button" onClick={onCancel}>
<i18n.Translate>Cancel</i18n.Translate> <div class="buttons mt-5">
</button>
<span <button class="button " onClick={onCancel}>
class="has-tooltip-left" <i18n.Translate>Clone</i18n.Translate>
data-tooltip={i18n.str`update product with new price`}
>
<button
class="button is-info"
onClick={() =>
onUpdate({
...product,
price: value.price,
})
}
>
<i18n.Translate>Confirm</i18n.Translate>
</button> </button>
</span> </div>
<div class="buttons is-right mt-5">
<button class="button" onClick={onCancel}>
<i18n.Translate>Cancel</i18n.Translate>
</button>
<span
class="has-tooltip-left"
data-tooltip={i18n.str`update product with new price`}
>
<button
class="button is-info"
onClick={() =>
onUpdate({
...product,
price: value.price,
})
}
>
<i18n.Translate>Confirm update</i18n.Translate>
</button>
</span>
</div>
</div> </div>
</Fragment> </Fragment>
); );
@ -374,9 +385,8 @@ function FastProductWithManagedStockUpdateForm({
const errors: FormErrors<FastProductUpdate> = { const errors: FormErrors<FastProductUpdate> = {
lost: lost:
currentStock + value.incoming < value.lost currentStock + value.incoming < value.lost
? `lost cannot be greater that current + incoming (max ${ ? `lost cannot be greater that current + incoming (max ${currentStock + value.incoming
currentStock + value.incoming })`
})`
: undefined, : undefined,
}; };

View File

@ -36,6 +36,7 @@ import {
import { Notification } from "../../../../utils/types.js"; import { Notification } from "../../../../utils/types.js";
import { CardTable } from "./Table.js"; import { CardTable } from "./Table.js";
import { HttpStatusCode } from "@gnu-taler/taler-util"; import { HttpStatusCode } from "@gnu-taler/taler-util";
import { ConfirmModal, DeleteModal } from "../../../../components/modal/index.js";
interface Props { interface Props {
onUnauthorized: () => VNode; onUnauthorized: () => VNode;
@ -53,6 +54,8 @@ export default function ProductList({
}: Props): VNode { }: Props): VNode {
const result = useInstanceProducts(); const result = useInstanceProducts();
const { deleteProduct, updateProduct } = useProductAPI(); const { deleteProduct, updateProduct } = useProductAPI();
const [deleting, setDeleting] =
useState<MerchantBackend.Products.ProductDetail & WithId | null>(null);
const [notif, setNotif] = useState<Notification | undefined>(undefined); const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -97,22 +100,43 @@ export default function ProductList({
} }
onSelect={(product) => onSelect(product.id)} onSelect={(product) => onSelect(product.id)}
onDelete={(prod: MerchantBackend.Products.ProductDetail & WithId) => onDelete={(prod: MerchantBackend.Products.ProductDetail & WithId) =>
deleteProduct(prod.id) setDeleting(prod)
.then(() =>
setNotif({
message: i18n.str`product delete successfully`,
type: "SUCCESS",
}),
)
.catch((error) =>
setNotif({
message: i18n.str`could not delete the product`,
type: "ERROR",
description: error.message,
}),
)
} }
/> />
{deleting && (
<ConfirmModal
label={`Delete product`}
description={`Delete the product "${deleting.description}"`}
danger
active
onCancel={() => setDeleting(null)}
onConfirm={async (): Promise<void> => {
try {
await deleteProduct(deleting.id);
setNotif({
message: i18n.str`Product "${deleting.description}" (ID: ${deleting.id}) has been deleted`,
type: "SUCCESS",
});
} catch (error) {
setNotif({
message: i18n.str`Failed to delete product`,
type: "ERROR",
description: error instanceof Error ? error.message : undefined,
});
}
setDeleting(null);
}}
>
<p>
If you delete the product named <b>&quot;{deleting.description}&quot;</b> (ID:{" "}
<b>{deleting.id}</b>), the stock and related information will be lost
</p>
<p class="warning">
Deleting an product <b>cannot be undone</b>.
</p>
</ConfirmModal>
)}
</section> </section>
); );
} }

View File

@ -36,7 +36,7 @@ import {
URL_REGEX, URL_REGEX,
} from "../../../../utils/constants.js"; } from "../../../../utils/constants.js";
type Entity = MerchantBackend.Tips.ReserveCreateRequest; type Entity = MerchantBackend.Rewards.ReserveCreateRequest;
interface Props { interface Props {
onCreate: (d: Entity) => Promise<void>; onCreate: (d: Entity) => Promise<void>;
@ -80,15 +80,15 @@ function ViewStep({
initial_balance: !reserve.initial_balance initial_balance: !reserve.initial_balance
? "cannot be empty" ? "cannot be empty"
: !(parseInt(reserve.initial_balance.split(":")[1], 10) > 0) : !(parseInt(reserve.initial_balance.split(":")[1], 10) > 0)
? i18n.str`it should be greater than 0` ? i18n.str`it should be greater than 0`
: undefined, : undefined,
exchange_url: !reserve.exchange_url exchange_url: !reserve.exchange_url
? i18n.str`cannot be empty` ? i18n.str`cannot be empty`
: !URL_REGEX.test(reserve.exchange_url) : !URL_REGEX.test(reserve.exchange_url)
? i18n.str`must be a valid URL` ? i18n.str`must be a valid URL`
: !exchangeQueryError : !exchangeQueryError
? undefined ? undefined
: exchangeQueryError, : exchangeQueryError,
}; };
const hasErrors = Object.keys(errors).some( const hasErrors = Object.keys(errors).some(

View File

@ -22,8 +22,8 @@ import { CreatedSuccessfully as Template } from "../../../../components/notifica
import { MerchantBackend, WireAccount } from "../../../../declaration.js"; import { MerchantBackend, WireAccount } from "../../../../declaration.js";
type Entity = { type Entity = {
request: MerchantBackend.Tips.ReserveCreateRequest; request: MerchantBackend.Rewards.ReserveCreateRequest;
response: MerchantBackend.Tips.ReserveCreateConfirmation; response: MerchantBackend.Rewards.ReserveCreateConfirmation;
}; };
interface Props { interface Props {
@ -98,15 +98,15 @@ export function ShowAccountsOfReserveAsQRWithLink({
const accountsInfo = !accounts const accountsInfo = !accounts
? [] ? []
: accounts : accounts
.map((acc) => { .map((acc) => {
const p = parsePaytoUri(acc.payto_uri); const p = parsePaytoUri(acc.payto_uri);
if (p) { if (p) {
p.params["message"] = message; p.params["message"] = message;
p.params["amount"] = amount; p.params["amount"] = amount;
} }
return p; return p;
}) })
.filter(isNotUndefined); .filter(isNotUndefined);
const links = accountsInfo.map((a) => stringifyPaytoUri(a)); const links = accountsInfo.map((a) => stringifyPaytoUri(a));

View File

@ -39,9 +39,9 @@ export default function CreateReserve({ onBack, onConfirm }: Props): VNode {
const [createdOk, setCreatedOk] = useState< const [createdOk, setCreatedOk] = useState<
| { | {
request: MerchantBackend.Tips.ReserveCreateRequest; request: MerchantBackend.Rewards.ReserveCreateRequest;
response: MerchantBackend.Tips.ReserveCreateConfirmation; response: MerchantBackend.Rewards.ReserveCreateConfirmation;
} }
| undefined | undefined
>(undefined); >(undefined);
@ -54,7 +54,7 @@ export default function CreateReserve({ onBack, onConfirm }: Props): VNode {
<NotificationCard notification={notif} /> <NotificationCard notification={notif} />
<CreatePage <CreatePage
onBack={onBack} onBack={onBack}
onCreate={(request: MerchantBackend.Tips.ReserveCreateRequest) => { onCreate={(request: MerchantBackend.Rewards.ReserveCreateRequest) => {
return createReserve(request) return createReserve(request)
.then((r) => setCreatedOk({ request, response: r.data })) .then((r) => setCreatedOk({ request, response: r.data }))
.catch((error) => { .catch((error) => {

View File

@ -36,11 +36,12 @@ import { InputDate } from "../../../../components/form/InputDate.js";
import { TextField } from "../../../../components/form/TextField.js"; import { TextField } from "../../../../components/form/TextField.js";
import { SimpleModal } from "../../../../components/modal/index.js"; import { SimpleModal } from "../../../../components/modal/index.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { useTipDetails } from "../../../../hooks/reserves.js"; import { useRewardDetails } from "../../../../hooks/reserves.js";
import { TipInfo } from "./TipInfo.js"; import { RewardInfo } from "./RewardInfo.js";
import { ShowAccountsOfReserveAsQRWithLink } from "../create/CreatedSuccessfully.js"; import { ShowAccountsOfReserveAsQRWithLink } from "../create/CreatedSuccessfully.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Tips.ReserveDetail; type Entity = MerchantBackend.Rewards.ReserveDetail;
type CT = MerchantBackend.ContractTerms; type CT = MerchantBackend.ContractTerms;
interface Props { interface Props {
@ -116,14 +117,14 @@ export function DetailPage({ id, selected, onBack }: Props): VNode {
<span class="icon"> <span class="icon">
<i class="mdi mdi-cash-register" /> <i class="mdi mdi-cash-register" />
</span> </span>
<i18n.Translate>Tips</i18n.Translate> <i18n.Translate>Rewards</i18n.Translate>
</p> </p>
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="b-table has-pagination"> <div class="b-table has-pagination">
<div class="table-wrapper has-mobile-cards"> <div class="table-wrapper has-mobile-cards">
{selected.tips && selected.tips.length > 0 ? ( {selected.rewards && selected.rewards.length > 0 ? (
<Table tips={selected.tips} /> <Table rewards={selected.rewards} />
) : ( ) : (
<EmptyTable /> <EmptyTable />
)} )}
@ -163,7 +164,7 @@ function EmptyTable(): VNode {
</p> </p>
<p> <p>
<i18n.Translate> <i18n.Translate>
No tips has been authorized from this reserve No reward has been authorized from this reserve
</i18n.Translate> </i18n.Translate>
</p> </p>
</div> </div>
@ -171,10 +172,10 @@ function EmptyTable(): VNode {
} }
interface TableProps { interface TableProps {
tips: MerchantBackend.Tips.TipStatusEntry[]; rewards: MerchantBackend.Rewards.RewardStatusEntry[];
} }
function Table({ tips }: TableProps): VNode { function Table({ rewards }: TableProps): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
return ( return (
<div class="table-container"> <div class="table-container">
@ -196,8 +197,8 @@ function Table({ tips }: TableProps): VNode {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tips.map((t, i) => { {rewards.map((t, i) => {
return <TipRow id={t.tip_id} key={i} entry={t} />; return <RewardRow id={t.reward_id} key={i} entry={t} />;
})} })}
</tbody> </tbody>
</table> </table>
@ -205,15 +206,16 @@ function Table({ tips }: TableProps): VNode {
); );
} }
function TipRow({ function RewardRow({
id, id,
entry, entry,
}: { }: {
id: string; id: string;
entry: MerchantBackend.Tips.TipStatusEntry; entry: MerchantBackend.Rewards.RewardStatusEntry;
}) { }) {
const [selected, setSelected] = useState(false); const [selected, setSelected] = useState(false);
const result = useTipDetails(id); const result = useRewardDetails(id);
const [settings] = useSettings();
if (result.loading) { if (result.loading) {
return ( return (
<tr> <tr>
@ -242,11 +244,11 @@ function TipRow({
<Fragment> <Fragment>
{selected && ( {selected && (
<SimpleModal <SimpleModal
description="tip" description="reward"
active active
onCancel={() => setSelected(false)} onCancel={() => setSelected(false)}
> >
<TipInfo id={id} amount={info.total_authorized} entity={info} /> <RewardInfo id={id} amount={info.total_authorized} entity={info} />
</SimpleModal> </SimpleModal>
)} )}
<tr> <tr>
@ -256,7 +258,7 @@ function TipRow({
<td onClick={onSelect}> <td onClick={onSelect}>
{info.expiration.t_s === "never" {info.expiration.t_s === "never"
? "never" ? "never"
: format(info.expiration.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} : format(info.expiration.t_s * 1000, datetimeFormatForSettings(settings))}
</td> </td>
</tr> </tr>
</Fragment> </Fragment>

View File

@ -92,7 +92,7 @@ export const NotYetFunded = createExample(TestedComponent, {
}, },
}); });
export const FundedWithEmptyTips = createExample(TestedComponent, { export const FundedWithEmptyRewards = createExample(TestedComponent, {
id: "THISISTHERESERVEID", id: "THISISTHERESERVEID",
selected: { selected: {
active: true, active: true,
@ -115,10 +115,10 @@ export const FundedWithEmptyTips = createExample(TestedComponent, {
}, },
], ],
exchange_url: "http://exchange.taler/", exchange_url: "http://exchange.taler/",
tips: [ rewards: [
{ {
reason: "asdasd", reason: "asdasd",
tip_id: "123", reward_id: "123",
total_amount: "TESTKUDOS:1", total_amount: "TESTKUDOS:1",
}, },
], ],

View File

@ -17,8 +17,10 @@ import { format } from "date-fns";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
import { stringifyRewardUri } from "@gnu-taler/taler-util";
type Entity = MerchantBackend.Tips.TipDetails; type Entity = MerchantBackend.Rewards.RewardDetails;
interface Props { interface Props {
id: string; id: string;
@ -26,11 +28,10 @@ interface Props {
amount: string; amount: string;
} }
export function TipInfo({ id, amount, entity }: Props): VNode { export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode {
const { url } = useBackendContext(); const { url: merchantBaseUrl } = useBackendContext();
const tipHost = url.replace(/.*:\/\//, ""); // remove protocol part const [settings] = useSettings();
const proto = url.startsWith("http://") ? "taler+http" : "taler"; const rewardURL = stringifyRewardUri({ merchantBaseUrl, merchantRewardId })
const tipURL = `${proto}://tip/${tipHost}/${id}`;
return ( return (
<Fragment> <Fragment>
<div class="field is-horizontal"> <div class="field is-horizontal">
@ -52,8 +53,8 @@ export function TipInfo({ id, amount, entity }: Props): VNode {
<div class="field-body is-flex-grow-3"> <div class="field-body is-flex-grow-3">
<div class="field" style={{ overflowWrap: "anywhere" }}> <div class="field" style={{ overflowWrap: "anywhere" }}>
<p class="control"> <p class="control">
<a target="_blank" rel="noreferrer" href={tipURL}> <a target="_blank" rel="noreferrer" href={rewardURL}>
{tipURL} {rewardURL}
</a> </a>
</p> </p>
</div> </div>
@ -73,9 +74,9 @@ export function TipInfo({ id, amount, entity }: Props): VNode {
!entity.expiration || entity.expiration.t_s === "never" !entity.expiration || entity.expiration.t_s === "never"
? "never" ? "never"
: format( : format(
entity.expiration.t_s * 1000, entity.expiration.t_s * 1000,
"yyyy/MM/dd HH:mm:ss", datetimeFormatForSettings(settings),
) )
} }
/> />
</p> </p>

View File

@ -34,32 +34,32 @@ import {
ContinueModal, ContinueModal,
} from "../../../../components/modal/index.js"; } from "../../../../components/modal/index.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { AuthorizeTipSchema } from "../../../../schemas/index.js"; import { AuthorizeRewardSchema } from "../../../../schemas/index.js";
import { CreatedSuccessfully } from "./CreatedSuccessfully.js"; import { CreatedSuccessfully } from "./CreatedSuccessfully.js";
interface AuthorizeTipModalProps { interface AuthorizeRewardModalProps {
onCancel: () => void; onCancel: () => void;
onConfirm: (value: MerchantBackend.Tips.TipCreateRequest) => void; onConfirm: (value: MerchantBackend.Rewards.RewardCreateRequest) => void;
tipAuthorized?: { rewardAuthorized?: {
response: MerchantBackend.Tips.TipCreateConfirmation; response: MerchantBackend.Rewards.RewardCreateConfirmation;
request: MerchantBackend.Tips.TipCreateRequest; request: MerchantBackend.Rewards.RewardCreateRequest;
}; };
} }
export function AuthorizeTipModal({ export function AuthorizeRewardModal({
onCancel, onCancel,
onConfirm, onConfirm,
tipAuthorized, rewardAuthorized,
}: AuthorizeTipModalProps): VNode { }: AuthorizeRewardModalProps): VNode {
// const result = useOrderDetails(id) // const result = useOrderDetails(id)
type State = MerchantBackend.Tips.TipCreateRequest; type State = MerchantBackend.Rewards.RewardCreateRequest;
const [form, setValue] = useState<Partial<State>>({}); const [form, setValue] = useState<Partial<State>>({});
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
// const [errors, setErrors] = useState<FormErrors<State>>({}) // const [errors, setErrors] = useState<FormErrors<State>>({})
let errors: FormErrors<State> = {}; let errors: FormErrors<State> = {};
try { try {
AuthorizeTipSchema.validateSync(form, { abortEarly: false }); AuthorizeRewardSchema.validateSync(form, { abortEarly: false });
} catch (err) { } catch (err) {
if (err instanceof yup.ValidationError) { if (err instanceof yup.ValidationError) {
const yupErrors = err.inner as any[]; const yupErrors = err.inner as any[];
@ -77,12 +77,12 @@ export function AuthorizeTipModal({
const validateAndConfirm = () => { const validateAndConfirm = () => {
onConfirm(form as State); onConfirm(form as State);
}; };
if (tipAuthorized) { if (rewardAuthorized) {
return ( return (
<ContinueModal description="tip" active onConfirm={onCancel}> <ContinueModal description="reward" active onConfirm={onCancel}>
<CreatedSuccessfully <CreatedSuccessfully
entity={tipAuthorized.response} entity={rewardAuthorized.response}
request={tipAuthorized.request} request={rewardAuthorized.request}
onConfirm={onCancel} onConfirm={onCancel}
/> />
</ContinueModal> </ContinueModal>
@ -91,7 +91,7 @@ export function AuthorizeTipModal({
return ( return (
<ConfirmModal <ConfirmModal
description="tip" description="New reward"
active active
onCancel={onCancel} onCancel={onCancel}
disabled={hasErrors} disabled={hasErrors}
@ -105,18 +105,18 @@ export function AuthorizeTipModal({
<InputCurrency<State> <InputCurrency<State>
name="amount" name="amount"
label={i18n.str`Amount`} label={i18n.str`Amount`}
tooltip={i18n.str`amount of tip`} tooltip={i18n.str`amount of reward`}
/> />
<Input<State> <Input<State>
name="justification" name="justification"
label={i18n.str`Justification`} label={i18n.str`Justification`}
inputType="multiline" inputType="multiline"
tooltip={i18n.str`reason for the tip`} tooltip={i18n.str`reason for the reward`}
/> />
<Input<State> <Input<State>
name="next_url" name="next_url"
label={i18n.str`URL after tip`} label={i18n.str`URL after reward`}
tooltip={i18n.str`URL to visit after tip payment`} tooltip={i18n.str`URL to visit after reward payment`}
/> />
</FormProvider> </FormProvider>
</ConfirmModal> </ConfirmModal>

View File

@ -17,12 +17,13 @@ import { format } from "date-fns";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Tips.TipCreateConfirmation; type Entity = MerchantBackend.Rewards.RewardCreateConfirmation;
interface Props { interface Props {
entity: Entity; entity: Entity;
request: MerchantBackend.Tips.TipCreateRequest; request: MerchantBackend.Rewards.RewardCreateRequest;
onConfirm: () => void; onConfirm: () => void;
onCreateAnother?: () => void; onCreateAnother?: () => void;
} }
@ -33,6 +34,7 @@ export function CreatedSuccessfully({
onConfirm, onConfirm,
onCreateAnother, onCreateAnother,
}: Props): VNode { }: Props): VNode {
const [settings] = useSettings();
return ( return (
<Fragment> <Fragment>
<div class="field is-horizontal"> <div class="field is-horizontal">
@ -66,7 +68,7 @@ export function CreatedSuccessfully({
<div class="field-body is-flex-grow-3"> <div class="field-body is-flex-grow-3">
<div class="field"> <div class="field">
<p class="control"> <p class="control">
<input readonly class="input" value={entity.tip_status_url} /> <input readonly class="input" value={entity.reward_status_url} />
</p> </p>
</div> </div>
</div> </div>
@ -82,13 +84,13 @@ export function CreatedSuccessfully({
class="input" class="input"
readonly readonly
value={ value={
!entity.tip_expiration || !entity.reward_expiration ||
entity.tip_expiration.t_s === "never" entity.reward_expiration.t_s === "never"
? "never" ? "never"
: format( : format(
entity.tip_expiration.t_s * 1000, entity.reward_expiration.t_s * 1000,
"yyyy/MM/dd HH:mm:ss", datetimeFormatForSettings(settings),
) )
} }
/> />
</p> </p>

View File

@ -25,12 +25,6 @@ import { CardTable as TestedComponent } from "./Table.js";
export default { export default {
title: "Pages/Reserve/List", title: "Pages/Reserve/List",
component: TestedComponent, component: TestedComponent,
argTypes: {
onCreate: { action: "onCreate" },
onDelete: { action: "onDelete" },
onNewTip: { action: "onNewTip" },
onSelect: { action: "onSelect" },
},
}; };
function createExample<Props>( function createExample<Props>(

View File

@ -23,12 +23,13 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { format } from "date-fns"; import { format } from "date-fns";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Tips.ReserveStatusEntry & WithId; type Entity = MerchantBackend.Rewards.ReserveStatusEntry & WithId;
interface Props { interface Props {
instances: Entity[]; instances: Entity[];
onNewTip: (id: Entity) => void; onNewReward: (id: Entity) => void;
onSelect: (id: Entity) => void; onSelect: (id: Entity) => void;
onDelete: (id: Entity) => void; onDelete: (id: Entity) => void;
onCreate: () => void; onCreate: () => void;
@ -38,7 +39,7 @@ export function CardTable({
instances, instances,
onCreate, onCreate,
onSelect, onSelect,
onNewTip, onNewReward,
onDelete, onDelete,
}: Props): VNode { }: Props): VNode {
const [withoutFunds, withFunds] = instances.reduce((prev, current) => { const [withoutFunds, withFunds] = instances.reduce((prev, current) => {
@ -70,7 +71,7 @@ export function CardTable({
<div class="table-wrapper has-mobile-cards"> <div class="table-wrapper has-mobile-cards">
<TableWithoutFund <TableWithoutFund
instances={withoutFunds} instances={withoutFunds}
onNewTip={onNewTip} onNewReward={onNewReward}
onSelect={onSelect} onSelect={onSelect}
onDelete={onDelete} onDelete={onDelete}
/> />
@ -108,7 +109,7 @@ export function CardTable({
{withFunds.length > 0 ? ( {withFunds.length > 0 ? (
<Table <Table
instances={withFunds} instances={withFunds}
onNewTip={onNewTip} onNewReward={onNewReward}
onSelect={onSelect} onSelect={onSelect}
onDelete={onDelete} onDelete={onDelete}
/> />
@ -124,13 +125,14 @@ export function CardTable({
} }
interface TableProps { interface TableProps {
instances: Entity[]; instances: Entity[];
onNewTip: (id: Entity) => void; onNewReward: (id: Entity) => void;
onDelete: (id: Entity) => void; onDelete: (id: Entity) => void;
onSelect: (id: Entity) => void; onSelect: (id: Entity) => void;
} }
function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { function Table({ instances, onNewReward, onSelect, onDelete }: TableProps): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings();
return ( return (
<div class="table-container"> <div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
@ -164,7 +166,7 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode {
> >
{i.creation_time.t_s === "never" {i.creation_time.t_s === "never"
? "never" ? "never"
: format(i.creation_time.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))}
</td> </td>
<td <td
onClick={(): void => onSelect(i)} onClick={(): void => onSelect(i)}
@ -173,9 +175,9 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode {
{i.expiration_time.t_s === "never" {i.expiration_time.t_s === "never"
? "never" ? "never"
: format( : format(
i.expiration_time.t_s * 1000, i.expiration_time.t_s * 1000,
"yyyy/MM/dd HH:mm:ss", datetimeFormatForSettings(settings),
)} )}
</td> </td>
<td <td
onClick={(): void => onSelect(i)} onClick={(): void => onSelect(i)}
@ -207,11 +209,11 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode {
</button> </button>
<button <button
class="button is-small is-info has-tooltip-left" class="button is-small is-info has-tooltip-left"
data-tooltip={i18n.str`authorize new tip from selected reserve`} data-tooltip={i18n.str`authorize new reward from selected reserve`}
type="button" type="button"
onClick={(): void => onNewTip(i)} onClick={(): void => onNewReward(i)}
> >
New Tip New Reward
</button> </button>
</div> </div>
</td> </td>
@ -249,6 +251,7 @@ function TableWithoutFund({
onDelete, onDelete,
}: TableProps): VNode { }: TableProps): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings();
return ( return (
<div class="table-container"> <div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
@ -276,7 +279,7 @@ function TableWithoutFund({
> >
{i.creation_time.t_s === "never" {i.creation_time.t_s === "never"
? "never" ? "never"
: format(i.creation_time.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))}
</td> </td>
<td <td
onClick={(): void => onSelect(i)} onClick={(): void => onSelect(i)}
@ -285,9 +288,9 @@ function TableWithoutFund({
{i.expiration_time.t_s === "never" {i.expiration_time.t_s === "never"
? "never" ? "never"
: format( : format(
i.expiration_time.t_s * 1000, i.expiration_time.t_s * 1000,
"yyyy/MM/dd HH:mm:ss", datetimeFormatForSettings(settings),
)} )}
</td> </td>
<td <td
onClick={(): void => onSelect(i)} onClick={(): void => onSelect(i)}

View File

@ -34,9 +34,10 @@ import {
useReservesAPI, useReservesAPI,
} from "../../../../hooks/reserves.js"; } from "../../../../hooks/reserves.js";
import { Notification } from "../../../../utils/types.js"; import { Notification } from "../../../../utils/types.js";
import { AuthorizeTipModal } from "./AutorizeTipModal.js"; import { AuthorizeRewardModal } from "./AutorizeRewardModal.js";
import { CardTable } from "./Table.js"; import { CardTable } from "./Table.js";
import { HttpStatusCode } from "@gnu-taler/taler-util"; import { HttpStatusCode } from "@gnu-taler/taler-util";
import { ConfirmModal } from "../../../../components/modal/index.js";
interface Props { interface Props {
onUnauthorized: () => VNode; onUnauthorized: () => VNode;
@ -46,12 +47,12 @@ interface Props {
onCreate: () => void; onCreate: () => void;
} }
interface TipConfirmation { interface RewardConfirmation {
response: MerchantBackend.Tips.TipCreateConfirmation; response: MerchantBackend.Rewards.RewardCreateConfirmation;
request: MerchantBackend.Tips.TipCreateRequest; request: MerchantBackend.Rewards.RewardCreateRequest;
} }
export default function ListTips({ export default function ListRewards({
onUnauthorized, onUnauthorized,
onLoadError, onLoadError,
onNotFound, onNotFound,
@ -59,14 +60,16 @@ export default function ListTips({
onCreate, onCreate,
}: Props): VNode { }: Props): VNode {
const result = useInstanceReserves(); const result = useInstanceReserves();
const { deleteReserve, authorizeTipReserve } = useReservesAPI(); const { deleteReserve, authorizeRewardReserve } = useReservesAPI();
const [notif, setNotif] = useState<Notification | undefined>(undefined); const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [reserveForTip, setReserveForTip] = useState<string | undefined>( const [reserveForReward, setReserveForReward] = useState<string | undefined>(
undefined, undefined,
); );
const [tipAuthorized, setTipAuthorized] = useState< const [deleting, setDeleting] =
TipConfirmation | undefined useState<MerchantBackend.Rewards.ReserveStatusEntry | null>(null);
const [rewardAuthorized, setRewardAuthorized] = useState<
RewardConfirmation | undefined
>(undefined); >(undefined);
if (result.loading) return <Loading />; if (result.loading) return <Loading />;
@ -88,30 +91,30 @@ export default function ListTips({
<section class="section is-main-section"> <section class="section is-main-section">
<NotificationCard notification={notif} /> <NotificationCard notification={notif} />
{reserveForTip && ( {reserveForReward && (
<AuthorizeTipModal <AuthorizeRewardModal
onCancel={() => { onCancel={() => {
setReserveForTip(undefined); setReserveForReward(undefined);
setTipAuthorized(undefined); setRewardAuthorized(undefined);
}} }}
tipAuthorized={tipAuthorized} rewardAuthorized={rewardAuthorized}
onConfirm={async (request) => { onConfirm={async (request) => {
try { try {
const response = await authorizeTipReserve( const response = await authorizeRewardReserve(
reserveForTip, reserveForReward,
request, request,
); );
setTipAuthorized({ setRewardAuthorized({
request, request,
response: response.data, response: response.data,
}); });
} catch (error) { } catch (error) {
setNotif({ setNotif({
message: i18n.str`could not create the tip`, message: i18n.str`could not create the reward`,
type: "ERROR", type: "ERROR",
description: error instanceof Error ? error.message : undefined, description: error instanceof Error ? error.message : undefined,
}); });
setReserveForTip(undefined); setReserveForReward(undefined);
} }
}} }}
/> />
@ -122,10 +125,47 @@ export default function ListTips({
.filter((r) => r.active) .filter((r) => r.active)
.map((o) => ({ ...o, id: o.reserve_pub }))} .map((o) => ({ ...o, id: o.reserve_pub }))}
onCreate={onCreate} onCreate={onCreate}
onDelete={(reserve) => deleteReserve(reserve.reserve_pub)} onDelete={(reserve) => {
setDeleting(reserve)
}}
onSelect={(reserve) => onSelect(reserve.id)} onSelect={(reserve) => onSelect(reserve.id)}
onNewTip={(reserve) => setReserveForTip(reserve.id)} onNewReward={(reserve) => setReserveForReward(reserve.id)}
/> />
{deleting && (
<ConfirmModal
label={`Delete reserve`}
description={`Delete the reserve`}
danger
active
onCancel={() => setDeleting(null)}
onConfirm={async (): Promise<void> => {
try {
await deleteReserve(deleting.reserve_pub);
setNotif({
message: i18n.str`Reserve for "${deleting.merchant_initial_amount}" (ID: ${deleting.reserve_pub}) has been deleted`,
type: "SUCCESS",
});
} catch (error) {
setNotif({
message: i18n.str`Failed to delete reserve`,
type: "ERROR",
description: error instanceof Error ? error.message : undefined,
});
}
setDeleting(null);
}}
>
<p>
If you delete the reserve for <b>&quot;{deleting.merchant_initial_amount}&quot;</b> you won't be able to create more rewards. <br />
Reserve ID: <b>{deleting.reserve_pub}</b>
</p>
<p class="warning">
Deleting an template <b>cannot be undone</b>.
</p>
</ConfirmModal>
)}
</section> </section>
); );
} }

View File

@ -24,7 +24,7 @@ import {
MerchantTemplateContractDetails, MerchantTemplateContractDetails,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
import { import {
@ -35,17 +35,16 @@ import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { import {
isBase32RFC3548Charset, isBase32RFC3548Charset
randomBase32Key,
} from "../../../../utils/crypto.js"; } from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js"; import { undefinedIfEmpty } from "../../../../utils/table.js";
import { QR } from "../../../../components/exception/QR.js"; import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js";
import { useInstanceContext } from "../../../../context/instance.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
type Entity = MerchantBackend.Template.TemplateAddDetails; type Entity = MerchantBackend.Template.TemplateAddDetails;
@ -54,16 +53,11 @@ interface Props {
onBack?: () => void; onBack?: () => void;
} }
const algorithms = [0, 1, 2];
const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
export function CreatePage({ onCreate, onBack }: Props): VNode { export function CreatePage({ onCreate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const backend = useBackendContext();
const { id: instanceId } = useInstanceContext(); const devices = useInstanceOtpDevices()
const issuer = new URL(backend.url).hostname;
const [showKey, setShowKey] = useState(false);
const [state, setState] = useState<Partial<Entity>>({ const [state, setState] = useState<Partial<Entity>>({
template_contract: { template_contract: {
minimum_age: 0, minimum_age: 0,
@ -78,7 +72,11 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
: Amounts.parse(state.template_contract?.amount); : Amounts.parse(state.template_contract?.amount);
const errors: FormErrors<Entity> = { const errors: FormErrors<Entity> = {
template_id: !state.template_id ? i18n.str`should not be empty` : undefined, template_id: !state.template_id
? i18n.str`should not be empty`
: !/[a-zA-Z0-9]*/.test(state.template_id)
? i18n.str`no valid. only characters and numbers`
: undefined,
template_description: !state.template_description template_description: !state.template_description
? i18n.str`should not be empty` ? i18n.str`should not be empty`
: undefined, : undefined,
@ -104,15 +102,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
? i18n.str`to short` ? i18n.str`to short`
: undefined, : undefined,
} as Partial<MerchantTemplateContractDetails>), } as Partial<MerchantTemplateContractDetails>),
pos_key: !state.pos_key
? !state.pos_algorithm
? undefined
: i18n.str`required`
: !isBase32RFC3548Charset(state.pos_key)
? i18n.str`just letters and numbers from 2 to 7`
: state.pos_key.length !== 32
? i18n.str`size of the key should be 32`
: undefined,
}; };
const hasErrors = Object.keys(errors).some( const hasErrors = Object.keys(errors).some(
@ -124,7 +113,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
return onCreate(state as any); return onCreate(state as any);
}; };
const qrText = `otpauth://totp/${instanceId}/${state.template_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${state.pos_key}`; const deviceList = !devices.ok ? [] : devices.data.otp_devices
return ( return (
<div> <div>
@ -139,7 +128,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
> >
<InputWithAddon<Entity> <InputWithAddon<Entity>
name="template_id" name="template_id"
help={`${backend.url}/instances/templates/${state.template_id ?? ""}`} help={`${backend.url}/templates/${state.template_id ?? ""}`}
label={i18n.str`Identifier`} label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the template in URLs.`} tooltip={i18n.str`Name of the template in URLs.`}
/> />
@ -172,83 +161,21 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
help="" help=""
tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`}
/> />
<InputSelector<Entity> <Input<Entity>
name="pos_algorithm" name="otp_id"
label={i18n.str`Verification algorithm`} label={i18n.str`OTP device`}
tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} readonly
values={algorithms} tooltip={i18n.str`Use to verify transaction in offline mode.`}
toStr={(v) => algorithmsNames[v]}
fromStr={(v) => Number(v)}
/> />
{state.pos_algorithm && state.pos_algorithm > 0 ? ( <InputSearchOnList
<Fragment> label={i18n.str`Search device`}
<InputWithAddon<Entity> onChange={(p) => setState((v) => ({ ...v, otp_id: p?.id }))}
name="pos_key" list={deviceList.map(e => ({
label={i18n.str`Point-of-sale key`} description: e.device_description,
inputType={showKey ? "text" : "password"} id: e.otp_device_id
help="Be sure to be very hard to guess or use the random generator" }))}
tooltip={i18n.str`Useful to validate the purchase`} />
fromStr={(v) => v.toUpperCase()}
addonAfter={
<span class="icon">
{showKey ? (
<i class="mdi mdi-eye" />
) : (
<i class="mdi mdi-eye-off" />
)}
</span>
}
side={
<span style={{ display: "flex" }}>
<button
data-tooltip={i18n.str`generate random secret key`}
class="button is-info mr-3"
onClick={(e) => {
const pos_key = randomBase32Key();
setState((s) => ({ ...s, pos_key }));
}}
>
<i18n.Translate>random</i18n.Translate>
</button>
<button
data-tooltip={
showKey
? i18n.str`show secret key`
: i18n.str`hide secret key`
}
class="button is-info mr-3"
onClick={(e) => {
setShowKey(!showKey);
}}
>
{showKey ? (
<i18n.Translate>hide</i18n.Translate>
) : (
<i18n.Translate>show</i18n.Translate>
)}
</button>
</span>
}
/>
{showKey && (
<Fragment>
<QR text={qrText} />
<div
style={{
color: "grey",
fontSize: "small",
width: 200,
textAlign: "center",
margin: "auto",
wordBreak: "break-all",
}}
>
{qrText}
</div>
</Fragment>
)}
</Fragment>
) : undefined}
</FormProvider> </FormProvider>
<div class="buttons is-right mt-5"> <div class="buttons is-right mt-5">

View File

@ -36,6 +36,7 @@ import {
import { Notification } from "../../../../utils/types.js"; import { Notification } from "../../../../utils/types.js";
import { ListPage } from "./ListPage.js"; import { ListPage } from "./ListPage.js";
import { HttpStatusCode } from "@gnu-taler/taler-util"; import { HttpStatusCode } from "@gnu-taler/taler-util";
import { ConfirmModal } from "../../../../components/modal/index.js";
interface Props { interface Props {
onUnauthorized: () => VNode; onUnauthorized: () => VNode;
@ -61,6 +62,8 @@ export default function ListTemplates({
const [notif, setNotif] = useState<Notification | undefined>(undefined); const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { deleteTemplate } = useTemplateAPI(); const { deleteTemplate } = useTemplateAPI();
const result = useInstanceTemplates({ position }, (id) => setPosition(id)); const result = useInstanceTemplates({ position }, (id) => setPosition(id));
const [deleting, setDeleting] =
useState<MerchantBackend.Template.TemplateEntry | null>(null);
if (result.loading) return <Loading />; if (result.loading) return <Loading />;
if (!result.ok) { if (!result.ok) {
@ -97,23 +100,45 @@ export default function ListTemplates({
onQR={(e) => { onQR={(e) => {
onQR(e.template_id); onQR(e.template_id);
}} }}
onDelete={(e: MerchantBackend.Template.TemplateEntry) => onDelete={(e: MerchantBackend.Template.TemplateEntry) => {
deleteTemplate(e.template_id) setDeleting(e)
.then(() => }
setNotif({
message: i18n.str`template delete successfully`,
type: "SUCCESS",
}),
)
.catch((error) =>
setNotif({
message: i18n.str`could not delete the template`,
type: "ERROR",
description: error.message,
}),
)
} }
/> />
{deleting && (
<ConfirmModal
label={`Delete template`}
description={`Delete the template "${deleting.template_description}"`}
danger
active
onCancel={() => setDeleting(null)}
onConfirm={async (): Promise<void> => {
try {
await deleteTemplate(deleting.template_id);
setNotif({
message: i18n.str`Template "${deleting.template_description}" (ID: ${deleting.template_id}) has been deleted`,
type: "SUCCESS",
});
} catch (error) {
setNotif({
message: i18n.str`Failed to delete template`,
type: "ERROR",
description: error instanceof Error ? error.message : undefined,
});
}
setDeleting(null);
}}
>
<p>
If you delete the template <b>&quot;{deleting.template_description}&quot;</b> (ID:{" "}
<b>{deleting.template_id}</b>) you may loose information
</p>
<p class="warning">
Deleting an template <b>cannot be undone</b>.
</p>
</ConfirmModal>
)}
</Fragment> </Fragment>
); );
} }

View File

@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { QR } from "../../../../components/exception/QR.js"; import { QR } from "../../../../components/exception/QR.js";
@ -35,35 +35,32 @@ import { useConfigContext } from "../../../../context/config.js";
import { useInstanceContext } from "../../../../context/instance.js"; import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { stringifyPayTemplateUri } from "@gnu-taler/taler-util"; import { stringifyPayTemplateUri } from "@gnu-taler/taler-util";
import { useOtpDeviceDetails } from "../../../../hooks/otp.js";
import { Loading } from "../../../../components/exception/loading.js";
type Entity = MerchantBackend.Template.UsingTemplateDetails; type Entity = MerchantBackend.Template.UsingTemplateDetails;
interface Props { interface Props {
template: MerchantBackend.Template.TemplateDetails; contract: MerchantBackend.Template.TemplateContractDetails;
id: string; id: string;
onBack?: () => void; onBack?: () => void;
} }
export function QrPage({ template, id: templateId, onBack }: Props): VNode { export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const { url: backendUrl } = useBackendContext(); const { url: backendUrl } = useBackendContext();
const { id: instanceId } = useInstanceContext(); const { id: instanceId } = useInstanceContext();
const config = useConfigContext(); const config = useConfigContext();
const [setupTOTP, setSetupTOTP] = useState(false);
const [state, setState] = useState<Partial<Entity>>({ const [state, setState] = useState<Partial<Entity>>({
amount: template.template_contract.amount, amount: contract.amount,
summary: template.template_contract.summary, summary: contract.summary,
}); });
const errors: FormErrors<Entity> = {}; const errors: FormErrors<Entity> = {};
const hasErrors = Object.keys(errors).some( const fixedAmount = !!contract.amount;
(k) => (errors as any)[k] !== undefined, const fixedSummary = !!contract.summary;
);
const fixedAmount = !!template.template_contract.amount;
const fixedSummary = !!template.template_contract.summary;
const templateParams: Record<string, string> = {} const templateParams: Record<string, string> = {}
if (!fixedAmount) { if (!fixedAmount) {
@ -89,40 +86,9 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode {
const issuer = encodeURIComponent( const issuer = encodeURIComponent(
`${new URL(backendUrl).host}/${instanceId}`, `${new URL(backendUrl).host}/${instanceId}`,
); );
const oauthUri = !template.pos_algorithm
? undefined
: template.pos_algorithm === 1
? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: template.pos_algorithm === 2
? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: undefined;
const keySlice = template.pos_key?.substring(0, 4);
const oauthUriWithoutSecret = !template.pos_algorithm
? undefined
: template.pos_algorithm === 1
? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: template.pos_algorithm === 2
? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: undefined;
return ( return (
<div> <div>
{oauthUri && (
<ConfirmModal
description="Setup TOTP"
active={setupTOTP}
onCancel={() => {
setSetupTOTP(false);
}}
>
<p>Scan this qr code with your TOTP device</p>
<QR text={oauthUri} />
<pre style={{ textAlign: "center" }}>
<a href={oauthUri}>{oauthUriWithoutSecret}</a>
</pre>
</ConfirmModal>
)}
<section class="section is-main-section"> <section class="section is-main-section">
<div class="columns"> <div class="columns">
<div class="column" /> <div class="column" />
@ -176,14 +142,6 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode {
> >
<i18n.Translate>Print</i18n.Translate> <i18n.Translate>Print</i18n.Translate>
</button> </button>
{oauthUri && (
<button
class="button is-info"
onClick={() => setSetupTOTP(true)}
>
<i18n.Translate>Setup TOTP</i18n.Translate>
</button>
)}
</div> </div>
</div> </div>
<div class="column" /> <div class="column" />

View File

@ -74,7 +74,7 @@ export default function TemplateQrPage({
return ( return (
<> <>
<NotificationCard notification={notif} /> <NotificationCard notification={notif} />
<QrPage template={result.data} id={tid} onBack={onBack} /> <QrPage contract={result.data.template_contract} id={tid} onBack={onBack} />
</> </>
); );
} }

View File

@ -61,10 +61,7 @@ const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const backend = useBackendContext();
const { id: instanceId } = useInstanceContext();
const issuer = new URL(backend.url).hostname;
const [showKey, setShowKey] = useState(false);
const [state, setState] = useState<Partial<Entity>>(template); const [state, setState] = useState<Partial<Entity>>(template);
const parsedPrice = !state.template_contract?.amount const parsedPrice = !state.template_contract?.amount
@ -78,34 +75,25 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
template_contract: !state.template_contract template_contract: !state.template_contract
? undefined ? undefined
: undefinedIfEmpty({ : undefinedIfEmpty({
amount: !state.template_contract?.amount amount: !state.template_contract?.amount
? undefined ? undefined
: !parsedPrice : !parsedPrice
? i18n.str`not valid` ? i18n.str`not valid`
: Amounts.isZero(parsedPrice) : Amounts.isZero(parsedPrice)
? i18n.str`must be greater than 0` ? i18n.str`must be greater than 0`
: undefined,
minimum_age:
state.template_contract.minimum_age < 0
? i18n.str`should be greater that 0`
: undefined, : undefined,
pay_duration: !state.template_contract.pay_duration minimum_age:
? i18n.str`can't be empty` state.template_contract.minimum_age < 0
: state.template_contract.pay_duration.d_us === "forever" ? i18n.str`should be greater that 0`
: undefined,
pay_duration: !state.template_contract.pay_duration
? i18n.str`can't be empty`
: state.template_contract.pay_duration.d_us === "forever"
? undefined ? undefined
: state.template_contract.pay_duration.d_us < 1000 * 1000 // less than one second : state.template_contract.pay_duration.d_us < 1000 * 1000 // less than one second
? i18n.str`to short` ? i18n.str`to short`
: undefined, : undefined,
} as Partial<MerchantTemplateContractDetails>), } as Partial<MerchantTemplateContractDetails>),
pos_key: !state.pos_key
? !state.pos_algorithm
? undefined
: i18n.str`required`
: !isBase32RFC3548Charset(state.pos_key)
? i18n.str`just letters and numbers from 2 to 7`
: state.pos_key.length !== 32
? i18n.str`size of the key should be 32`
: undefined,
}; };
const hasErrors = Object.keys(errors).some( const hasErrors = Object.keys(errors).some(
@ -117,7 +105,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
return onUpdate(state as any); return onUpdate(state as any);
}; };
const qrText = `otpauth://totp/${instanceId}/${state.id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${state.pos_key}`;
return ( return (
<div> <div>
@ -128,7 +115,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<span class="is-size-4"> <span class="is-size-4">
{backend.url}/instances/template/{template.id} {backend.url}/templates/{template.id}
</span> </span>
</div> </div>
</div> </div>
@ -182,84 +169,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
help="" help=""
tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`}
/> />
<InputSelector<Entity>
name="pos_algorithm"
label={i18n.str`Verification algorithm`}
tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`}
values={algorithms}
toStr={(v) => algorithmsNames[v]}
fromStr={(v) => Number(v)}
/>
{state.pos_algorithm && state.pos_algorithm > 0 ? (
<Fragment>
<InputWithAddon<Entity>
name="pos_key"
label={i18n.str`Point-of-sale key`}
inputType={showKey ? "text" : "password"}
help="Be sure to be very hard to guess or use the random generator"
expand
tooltip={i18n.str`Useful to validate the purchase`}
fromStr={(v) => v.toUpperCase()}
addonAfter={
<span class="icon">
{showKey ? (
<i class="mdi mdi-eye" />
) : (
<i class="mdi mdi-eye-off" />
)}
</span>
}
side={
<span style={{ display: "flex" }}>
<button
data-tooltip={i18n.str`generate random secret key`}
class="button is-info mr-3"
onClick={(e) => {
const pos_key = randomBase32Key();
setState((s) => ({ ...s, pos_key }));
}}
>
<i18n.Translate>random</i18n.Translate>
</button>
<button
data-tooltip={
showKey
? i18n.str`show secret key`
: i18n.str`hide secret key`
}
class="button is-info mr-3"
onClick={(e) => {
setShowKey(!showKey);
}}
>
{showKey ? (
<i18n.Translate>hide</i18n.Translate>
) : (
<i18n.Translate>show</i18n.Translate>
)}
</button>
</span>
}
/>
{showKey && (
<Fragment>
<QR text={qrText} />
<div
style={{
color: "grey",
fontSize: "small",
width: 200,
textAlign: "center",
margin: "auto",
wordBreak: "break-all",
}}
>
{qrText}
</div>
</Fragment>
)}
</Fragment>
) : undefined}
</FormProvider> </FormProvider>
<div class="buttons is-right mt-5"> <div class="buttons is-right mt-5">

View File

@ -0,0 +1,165 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { AsyncButton } from "../../../components/exception/AsyncButton.js";
import { FormProvider } from "../../../components/form/FormProvider.js";
import { Input } from "../../../components/form/Input.js";
import { useInstanceContext } from "../../../context/instance.js";
interface Props {
instanceId: string;
currentToken: string | undefined;
onClearToken: () => void;
onNewToken: (s: string) => void;
onBack?: () => void;
}
export function DetailPage({ instanceId, currentToken: oldToken, onBack, onNewToken, onClearToken }: Props): VNode {
type State = { old_token: string; new_token: string; repeat_token: string };
const [form, setValue] = useState<Partial<State>>({
old_token: "",
new_token: "",
repeat_token: "",
});
const { i18n } = useTranslationContext();
const hasOldtoken = !!oldToken
const hasInputTheCorrectOldToken = hasOldtoken && oldToken !== form.old_token;
const errors = {
old_token: hasInputTheCorrectOldToken
? i18n.str`is not the same as the current access token`
: undefined,
new_token: !form.new_token
? i18n.str`cannot be empty`
: form.new_token === form.old_token
? i18n.str`cannot be the same as the old token`
: undefined,
repeat_token:
form.new_token !== form.repeat_token
? i18n.str`is not the same`
: undefined,
};
const hasErrors = Object.keys(errors).some(
(k) => (errors as any)[k] !== undefined,
);
const instance = useInstanceContext();
const text = i18n.str`You are updating the access token from instance with id ${instance.id}`;
async function submitForm() {
if (hasErrors) return;
onNewToken(form.new_token as any)
}
return (
<div>
<section class="section">
<section class="hero is-hero-bar">
<div class="hero-body">
<div class="level">
<div class="level-left">
<div class="level-item">
<span class="is-size-4">
Instance id: <b>{instanceId}</b>
</span>
</div>
</div>
</div>
</div>
</section>
<hr />
<div class="columns">
<div class="column" />
<div class="column is-four-fifths">
<FormProvider errors={errors} object={form} valueHandler={setValue}>
{hasOldtoken && (
<Input<State>
name="old_token"
label={i18n.str`Current access token`}
tooltip={i18n.str`access token currently in use`}
inputType="password"
/>
)}
{!hasInputTheCorrectOldToken && <Fragment>
{hasOldtoken && <Fragment>
<p>
<i18n.Translate>
Clearing the access token will mean public access to the instance.
</i18n.Translate>
</p>
<div class="buttons is-right mt-5">
<button
disabled={!!hasInputTheCorrectOldToken}
class="button"
onClick={onClearToken}
>
<i18n.Translate>Clear token</i18n.Translate>
</button>
</div>
</Fragment>
}
<Input<State>
name="new_token"
label={i18n.str`New access token`}
tooltip={i18n.str`next access token to be used`}
inputType="password"
/>
<Input<State>
name="repeat_token"
label={i18n.str`Repeat access token`}
tooltip={i18n.str`confirm the same access token`}
inputType="password"
/>
</Fragment>}
</FormProvider>
<div class="buttons is-right mt-5">
{onBack && (
<button class="button" onClick={onBack}>
<i18n.Translate>Cancel</i18n.Translate>
</button>
)}
<AsyncButton
disabled={hasErrors}
data-tooltip={
hasErrors
? i18n.str`Need to complete marked fields`
: "confirm operation"
}
onClick={submitForm}
>
<i18n.Translate>Confirm change</i18n.Translate>
</AsyncButton>
</div>
</div>
<div class="column" />
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,90 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { HttpStatusCode } from "@gnu-taler/taler-util";
import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { Loading } from "../../../components/exception/loading.js";
import { MerchantBackend } from "../../../declaration.js";
import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js";
import { DetailPage } from "./DetailPage.js";
import { useInstanceContext } from "../../../context/instance.js";
import { useState } from "preact/hooks";
import { NotificationCard } from "../../../components/menu/index.js";
import { Notification } from "../../../utils/types.js";
import { useBackendContext } from "../../../context/backend.js";
interface Props {
onUnauthorized: () => VNode;
onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
onChange: () => void;
onNotFound: () => VNode;
}
const PREFIX = "secret-token:"
export default function Token({
onLoadError,
onChange,
onUnauthorized,
onNotFound,
}: Props): VNode {
const { i18n } = useTranslationContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { clearToken, setNewToken } = useInstanceAPI();
const { token: rootToken } = useBackendContext();
const { token: instanceToken, id, admin } = useInstanceContext();
const currentToken = !admin ? rootToken : instanceToken
const hasPrefix = currentToken !== undefined && currentToken.startsWith(PREFIX)
return (
<Fragment>
<NotificationCard notification={notif} />
<DetailPage
instanceId={id}
currentToken={hasPrefix ? currentToken.substring(PREFIX.length) : currentToken}
onClearToken={async (): Promise<void> => {
try {
await clearToken();
onChange();
} catch (error) {
if (error instanceof Error) {
setNotif({
message: i18n.str`Failed to clear token`,
type: "ERROR",
description: error.message,
});
}
}
}}
onNewToken={async (newToken): Promise<void> => {
try {
await setNewToken(`secret-token:${newToken}`);
onChange();
} catch (error) {
if (error instanceof Error) {
setNotif({
message: i18n.str`Failed to set new token`,
type: "ERROR",
description: error.message,
});
}
}
}}
/>
</Fragment>
);
}

View File

@ -0,0 +1,28 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { DetailPage as TestedComponent } from "./DetailPage.js";
export default {
title: "Pages/Token",
component: TestedComponent,
};

View File

@ -28,6 +28,7 @@ import { useInstanceDetails } from "../../../../hooks/instance.js";
import { useTransferAPI } from "../../../../hooks/transfer.js"; import { useTransferAPI } from "../../../../hooks/transfer.js";
import { Notification } from "../../../../utils/types.js"; import { Notification } from "../../../../utils/types.js";
import { CreatePage } from "./CreatePage.js"; import { CreatePage } from "./CreatePage.js";
import { useBankAccountDetails, useInstanceBankAccounts } from "../../../../hooks/bank.js";
export type Entity = MerchantBackend.Transfers.TransferInformation; export type Entity = MerchantBackend.Transfers.TransferInformation;
interface Props { interface Props {
@ -39,7 +40,7 @@ export default function CreateTransfer({ onConfirm, onBack }: Props): VNode {
const { informTransfer } = useTransferAPI(); const { informTransfer } = useTransferAPI();
const [notif, setNotif] = useState<Notification | undefined>(undefined); const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const instance = useInstanceDetails(); const instance = useInstanceBankAccounts();
const accounts = !instance.ok const accounts = !instance.ok
? [] ? []
: instance.data.accounts.map((a) => a.payto_uri); : instance.data.accounts.map((a) => a.payto_uri);

View File

@ -24,6 +24,7 @@ import { format } from "date-fns";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { StateUpdater, useState } from "preact/hooks"; import { StateUpdater, useState } from "preact/hooks";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Transfers.TransferDetails & WithId; type Entity = MerchantBackend.Transfers.TransferDetails & WithId;
@ -56,7 +57,7 @@ export function CardTable({
<header class="card-header"> <header class="card-header">
<p class="card-header-title"> <p class="card-header-title">
<span class="icon"> <span class="icon">
<i class="mdi mdi-bank" /> <i class="mdi mdi-arrow-left-right" />
</span> </span>
<i18n.Translate>Transfers</i18n.Translate> <i18n.Translate>Transfers</i18n.Translate>
</p> </p>
@ -121,6 +122,7 @@ function Table({
hasMoreBefore, hasMoreBefore,
}: TableProps): VNode { }: TableProps): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings();
return ( return (
<div class="table-container"> <div class="table-container">
{onLoadMoreBefore && ( {onLoadMoreBefore && (
@ -175,9 +177,9 @@ function Table({
? i.execution_time.t_s == "never" ? i.execution_time.t_s == "never"
? i18n.str`never` ? i18n.str`never`
: format( : format(
i.execution_time.t_s * 1000, i.execution_time.t_s * 1000,
"yyyy/MM/dd HH:mm:ss", datetimeFormatForSettings(settings),
) )
: i18n.str`unknown`} : i18n.str`unknown`}
</td> </td>
<td> <td>

View File

@ -28,6 +28,7 @@ import { useInstanceDetails } from "../../../../hooks/instance.js";
import { useInstanceTransfers } from "../../../../hooks/transfer.js"; import { useInstanceTransfers } from "../../../../hooks/transfer.js";
import { ListPage } from "./ListPage.js"; import { ListPage } from "./ListPage.js";
import { HttpStatusCode } from "@gnu-taler/taler-util"; import { HttpStatusCode } from "@gnu-taler/taler-util";
import { useInstanceBankAccounts } from "../../../../hooks/bank.js";
interface Props { interface Props {
onUnauthorized: () => VNode; onUnauthorized: () => VNode;
@ -51,7 +52,7 @@ export default function ListTransfer({
const [position, setPosition] = useState<string | undefined>(undefined); const [position, setPosition] = useState<string | undefined>(undefined);
const instance = useInstanceDetails(); const instance = useInstanceBankAccounts();
const accounts = !instance.ok const accounts = !instance.ok
? [] ? []
: instance.data.accounts.map((a) => a.payto_uri); : instance.data.accounts.map((a) => a.payto_uri);

View File

@ -42,17 +42,15 @@ function createExample<Props>(
export const Example = createExample(TestedComponent, { export const Example = createExample(TestedComponent, {
selected: { selected: {
accounts: [],
name: "name", name: "name",
auth: { method: "external" }, auth: { method: "external" },
address: {}, address: {},
user_type: "business",
use_stefan: true,
jurisdiction: {}, jurisdiction: {},
default_max_deposit_fee: "TESTKUDOS:2",
default_max_wire_fee: "TESTKUDOS:1",
default_pay_delay: { default_pay_delay: {
d_us: 1000 * 1000, //one second d_us: 1000 * 1000, //one second
}, },
default_wire_fee_amortization: 1,
default_wire_transfer_delay: { default_wire_transfer_delay: {
d_us: 1000 * 1000, //one second d_us: 1000 * 1000, //one second
}, },

View File

@ -19,7 +19,6 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { Amounts } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
@ -29,10 +28,8 @@ import {
FormProvider, FormProvider,
} from "../../../components/form/FormProvider.js"; } from "../../../components/form/FormProvider.js";
import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js";
import { UpdateTokenModal } from "../../../components/modal/index.js";
import { useInstanceContext } from "../../../context/instance.js"; import { useInstanceContext } from "../../../context/instance.js";
import { MerchantBackend } from "../../../declaration.js"; import { MerchantBackend } from "../../../declaration.js";
import { PAYTO_REGEX } from "../../../utils/constants.js";
import { undefinedIfEmpty } from "../../../utils/table.js"; import { undefinedIfEmpty } from "../../../utils/table.js";
type Entity = MerchantBackend.Instances.InstanceReconfigurationMessage & { type Entity = MerchantBackend.Instances.InstanceReconfigurationMessage & {
@ -53,23 +50,23 @@ interface Props {
function convert( function convert(
from: MerchantBackend.Instances.QueryInstancesResponse, from: MerchantBackend.Instances.QueryInstancesResponse,
): Entity { ): Entity {
const { accounts: qAccounts, ...rest } = from; const { ...rest } = from;
const accounts = qAccounts // const accounts = qAccounts
.filter((a) => a.active) // .filter((a) => a.active)
.map( // .map(
(a) => // (a) =>
({ // ({
payto_uri: a.payto_uri, // payto_uri: a.payto_uri,
credit_facade_url: a.credit_facade_url, // credit_facade_url: a.credit_facade_url,
credit_facade_credentials: a.credit_facade_credentials, // credit_facade_credentials: a.credit_facade_credentials,
} as MerchantBackend.Instances.MerchantBankAccount), // } as MerchantBackend.Instances.MerchantBankAccount),
); // );
const defaults = { const defaults = {
default_wire_fee_amortization: 1, use_stefan: false,
default_pay_delay: { d_us: 2 * 1000 * 1000 * 60 * 60 }, //two hours default_pay_delay: { d_us: 2 * 1000 * 1000 * 60 * 60 }, //two hours
default_wire_transfer_delay: { d_us: 2 * 1000 * 1000 * 60 * 60 * 2 }, //two hours default_wire_transfer_delay: { d_us: 2 * 1000 * 1000 * 60 * 60 * 2 }, //two hours
}; };
return { ...defaults, ...rest, accounts }; return { ...defaults, ...rest };
} }
function getTokenValuePart(t?: string): string | undefined { function getTokenValuePart(t?: string): string | undefined {
@ -85,21 +82,21 @@ export function UpdatePage({
selected, selected,
onBack, onBack,
}: Props): VNode { }: Props): VNode {
const { id, token } = useInstanceContext(); const { id } = useInstanceContext();
const currentTokenValue = getTokenValuePart(token); // const currentTokenValue = getTokenValuePart(token);
function updateToken(token: string | undefined | null) { // function updateToken(token: string | undefined | null) {
const value = // const value =
token && token.startsWith("secret-token:") // token && token.startsWith("secret-token:")
? token.substring("secret-token:".length) // ? token.substring("secret-token:".length)
: token; // : token;
if (!token) { // if (!token) {
onChangeAuth({ method: "external" }); // onChangeAuth({ method: "external" });
} else { // } else {
onChangeAuth({ method: "token", token: `secret-token:${value}` }); // onChangeAuth({ method: "token", token: `secret-token:${value}` });
} // }
} // }
const [value, valueHandler] = useState<Partial<Entity>>(convert(selected)); const [value, valueHandler] = useState<Partial<Entity>>(convert(selected));
@ -110,35 +107,7 @@ export function UpdatePage({
user_type: !value.user_type user_type: !value.user_type
? i18n.str`required` ? i18n.str`required`
: value.user_type !== "business" && value.user_type !== "individual" : value.user_type !== "business" && value.user_type !== "individual"
? i18n.str`should be business or individual` ? i18n.str`should be business or individual`
: undefined,
accounts:
!value.accounts || !value.accounts.length
? i18n.str`required`
: undefinedIfEmpty(
value.accounts.map((p) => {
return !PAYTO_REGEX.test(p.payto_uri)
? i18n.str`is not valid`
: undefined;
}),
),
default_max_deposit_fee: !value.default_max_deposit_fee
? i18n.str`required`
: !Amounts.parse(value.default_max_deposit_fee)
? i18n.str`invalid format`
: undefined,
default_max_wire_fee: !value.default_max_wire_fee
? i18n.str`required`
: !Amounts.parse(value.default_max_wire_fee)
? i18n.str`invalid format`
: undefined,
default_wire_fee_amortization:
value.default_wire_fee_amortization === undefined
? i18n.str`required`
: isNaN(value.default_wire_fee_amortization)
? i18n.str`is not a number`
: value.default_wire_fee_amortization < 1
? i18n.str`must be 1 or greater`
: undefined, : undefined,
default_pay_delay: !value.default_pay_delay default_pay_delay: !value.default_pay_delay
? i18n.str`required` ? i18n.str`required`
@ -163,10 +132,11 @@ export function UpdatePage({
const hasErrors = Object.keys(errors).some( const hasErrors = Object.keys(errors).some(
(k) => (errors as any)[k] !== undefined, (k) => (errors as any)[k] !== undefined,
); );
const submit = async (): Promise<void> => { const submit = async (): Promise<void> => {
await onUpdate(value as Entity); await onUpdate(value as Entity);
}; };
const [active, setActive] = useState(false); // const [active, setActive] = useState(false);
return ( return (
<div> <div>
@ -181,7 +151,7 @@ export function UpdatePage({
</span> </span>
</div> </div>
</div> </div>
<div class="level-right"> {/* <div class="level-right">
<div class="level-item"> <div class="level-item">
<h1 class="title"> <h1 class="title">
<button <button
@ -200,33 +170,11 @@ export function UpdatePage({
</button> </button>
</h1> </h1>
</div> </div>
</div> </div> */}
</div> </div>
</div> </div>
</section> </section>
<div class="columns">
<div class="column" />
<div class="column is-four-fifths">
{active && (
<UpdateTokenModal
oldToken={currentTokenValue}
onCancel={() => {
setActive(false);
}}
onClear={() => {
updateToken(null);
setActive(false);
}}
onConfirm={(newToken) => {
updateToken(newToken);
setActive(false);
}}
/>
)}
</div>
<div class="column" />
</div>
<hr /> <hr />
<div class="columns"> <div class="columns">

View File

@ -0,0 +1,28 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { h, VNode, FunctionalComponent } from "preact";
import { CreatePage as TestedComponent } from "./CreatePage.js";
export default {
title: "Pages/Validators/Create",
component: TestedComponent,
};

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