diff options
441 files changed, 12530 insertions, 32001 deletions
diff --git a/packages/aml-backoffice-ui/package.json b/packages/aml-backoffice-ui/package.json index 9c33862f7..c3549ef52 100644 --- a/packages/aml-backoffice-ui/package.json +++ b/packages/aml-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/aml-backoffice-ui", - "version": "0.11.4", + "version": "0.12.2", "author": "sebasjm", "license": "AGPL-3.0-OR-LATER", "description": "Back-office SPA for GNU Taler Exchange.", diff --git a/packages/anastasis-cli/package.json b/packages/anastasis-cli/package.json index 40bdb927e..47d1505d1 100644 --- a/packages/anastasis-cli/package.json +++ b/packages/anastasis-cli/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/anastasis-cli", - "version": "0.11.4", + "version": "0.12.2", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/anastasis-core/package.json b/packages/anastasis-core/package.json index c987f0ceb..c89b8eecc 100644 --- a/packages/anastasis-core/package.json +++ b/packages/anastasis-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/anastasis-core", - "version": "0.11.4", + "version": "0.12.2", "description": "", "main": "./lib/index.js", "module": "./lib/index.js", diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json index 9f56489d1..17e8e74fc 100644 --- a/packages/anastasis-webui/package.json +++ b/packages/anastasis-webui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/anastasis-webui", - "version": "0.11.4", + "version": "0.12.2", "license": "MIT", "type": "module", "scripts": { diff --git a/packages/auditor-backoffice-ui/README.md b/packages/auditor-backoffice-ui/README.md index b10fa6a94..03f4403b4 100644 --- a/packages/auditor-backoffice-ui/README.md +++ b/packages/auditor-backoffice-ui/README.md @@ -1,4 +1,4 @@ -## AUditor Admin Frontend + ## AUditor Admin Frontend Auditor Admin Frontend is a Single Page Application (SPA) that connects with a running Auditor Backend and lets you audit the exchange. diff --git a/packages/auditor-backoffice-ui/dev.mjs b/packages/auditor-backoffice-ui/dev.mjs index 14d5737de..d2299dad4 100755 --- a/packages/auditor-backoffice-ui/dev.mjs +++ b/packages/auditor-backoffice-ui/dev.mjs @@ -18,7 +18,7 @@ import { serve } from "@gnu-taler/web-util/node"; import { initializeDev } from "@gnu-taler/web-util/build"; -const devEntryPoints = ["src/stories.tsx", "src/index.tsx"]; +const devEntryPoints = ["src/index.tsx"]; const build = initializeDev({ type: "development", diff --git a/packages/auditor-backoffice-ui/package.json b/packages/auditor-backoffice-ui/package.json index ce420417c..bbebabf39 100644 --- a/packages/auditor-backoffice-ui/package.json +++ b/packages/auditor-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/auditor-backoffice-ui", - "version": "0.11.4", + "version": "0.12.2", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/packages/auditor-backoffice-ui/src/AdminRoutes.tsx b/packages/auditor-backoffice-ui/src/AdminRoutes.tsx deleted file mode 100644 index 91dec09b0..000000000 --- a/packages/auditor-backoffice-ui/src/AdminRoutes.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - 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 { h, VNode } from "preact"; -import { Router, route, Route } from "preact-router"; -import InstanceCreatePage from "./paths/admin/create/index.js"; -import InstanceListPage from "./paths/admin/list/index.js"; - -export enum AdminPaths { - list_instances = "/instances", - new_instance = "/instance/new", -} - -export function AdminRoutes(): VNode { - return ( - <Router> - <Route - path={AdminPaths.list_instances} - component={InstanceListPage} - onCreate={() => { - route(AdminPaths.new_instance); - }} - onUpdate={(id: string): void => { - route(`/instance/${id}/update`); - }} - /> - - <Route - path={AdminPaths.new_instance} - component={InstanceCreatePage} - onBack={() => route(AdminPaths.list_instances)} - onConfirm={() => { - // route(AdminPaths.list_instances); - }} - - // onError={(error: any) => { - // }} - /> - </Router> - ); -} diff --git a/packages/auditor-backoffice-ui/src/Application.tsx b/packages/auditor-backoffice-ui/src/Application.tsx index 3e5cfc273..3b6aa8dd3 100644 --- a/packages/auditor-backoffice-ui/src/Application.tsx +++ b/packages/auditor-backoffice-ui/src/Application.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -17,149 +17,150 @@ /** * * @author Sebastian Javier Marchano (sebasjm) + * @author Nic Eigel */ -import { HttpStatusCode, LibtoolVersion } from "@gnu-taler/taler-util"; +import {HttpStatusCode, LibtoolVersion} from "@gnu-taler/taler-util"; import { - ErrorType, - TranslationProvider, - useTranslationContext, + ErrorType, + TranslationProvider, + useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useMemo } from "preact/hooks"; -import { ApplicationReadyRoutes } from "./ApplicationReadyRoutes.js"; -import { Loading } from "./components/exception/loading.js"; +import {Fragment, VNode, h, render} from "preact"; +import {useMemo} from "preact/hooks"; +import {ApplicationReadyRoutes} from "./ApplicationReadyRoutes.js"; +import {Loading} from "./components/exception/loading.js"; import { - NotConnectedAppMenu, - NotificationCard + NotConnectedAppMenu, + NotificationCard } from "./components/menu/index.js"; import { - BackendContextProvider + BackendContextProvider } from "./context/backend.js"; -import { ConfigContextProvider } from "./context/config.js"; -import { useBackendConfig } from "./hooks/backend.js"; +import {ConfigContextProvider} from "./context/config.js"; +import {useBackendConfig} from "./hooks/backend.js"; import { strings } from "./i18n/strings.js"; export function Application(): VNode { - return ( - <BackendContextProvider> - <TranslationProvider source={strings}> - <ApplicationStatusRoutes /> - </TranslationProvider> - </BackendContextProvider> - ); + return ( + <BackendContextProvider> + <TranslationProvider source={strings}> + <ApplicationStatusRoutes/> + </TranslationProvider> + </BackendContextProvider> + ); } /** * Check connection testing against /config - * - * @returns + * + * @returns */ function ApplicationStatusRoutes(): VNode { - const result = useBackendConfig(); - const { i18n } = useTranslationContext(); + const result = useBackendConfig(); + const {i18n} = useTranslationContext(); - const { currency, version } = result.ok && result.data - ? result.data - : { currency: "unknown", version: "unknown" }; - const ctx = useMemo(() => ({ currency, version }), [currency, version]); + const configData = result.ok && result.data + ? result.data + : undefined; + const ctx = useMemo(() => (configData), [configData]); - if (!result.ok) { - if (result.loading) return <Loading />; - if ( - result.type === ErrorType.CLIENT && - result.status === HttpStatusCode.Unauthorized - ) { - return ( - <Fragment> - <NotConnectedAppMenu title="Login" /> - <NotificationCard - notification={{ - message: i18n.str`Checking the /config endpoint got authorization error`, - type: "ERROR", - description: `The /config endpoint of the backend server should be accessible`, - }} - /> - </Fragment> - ); + if (!result.ok) { + if (result.loading) return <Loading/>; + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.Unauthorized + ) { + return ( + <Fragment> + <NotConnectedAppMenu title="Login"/> + <NotificationCard + notification={{ + message: i18n.str`Checking the /config endpoint got authorization error`, + type: "ERROR", + description: `The /config endpoint of the backend server should be accessible`, + }} + /> + </Fragment> + ); + } + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.NotFound + ) { + return ( + <Fragment> + <NotConnectedAppMenu title="Error"/> + <NotificationCard + notification={{ + message: i18n.str`Could not find /config endpoint on this URL`, + type: "ERROR", + description: `Check the URL or contact the system administrator.`, + }} + /> + </Fragment> + ); + } + if (result.type === ErrorType.SERVER) { + <Fragment> + <NotConnectedAppMenu title="Error"/> + <NotificationCard + notification={{ + message: i18n.str`Server response with an error code`, + type: "ERROR", + description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, + }} + /> + </Fragment>; + } + if (result.type === ErrorType.UNREADABLE) { + <Fragment> + <NotConnectedAppMenu title="Error"/> + <NotificationCard + notification={{ + message: i18n.str`Response from server is unreadable, http status: ${result.status}`, + type: "ERROR", + description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, + }} + /> + </Fragment>; + } + return ( + <Fragment> + <NotConnectedAppMenu title="Error"/> + <NotificationCard + notification={{ + message: i18n.str`Unexpected Error`, + type: "ERROR", + description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, + }} + /> + </Fragment> + ); } - if ( - result.type === ErrorType.CLIENT && - result.status === HttpStatusCode.NotFound - ) { - return ( - <Fragment> - <NotConnectedAppMenu title="Error" /> - <NotificationCard - notification={{ - message: i18n.str`Could not find /config endpoint on this URL`, - type: "ERROR", - description: `Check the URL or contact the system administrator.`, - }} - /> + + const SUPPORTED_VERSION = "1:0:1" + if (result.data && !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`Auditor backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`, + }} + /> </Fragment> - ); - } - if (result.type === ErrorType.SERVER) { - <Fragment> - <NotConnectedAppMenu title="Error" /> - <NotificationCard - notification={{ - message: i18n.str`Server response with an error code`, - type: "ERROR", - description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, - }} - /> - </Fragment>; - } - if (result.type === ErrorType.UNREADABLE) { - <Fragment> - <NotConnectedAppMenu title="Error" /> - <NotificationCard - notification={{ - message: i18n.str`Response from server is unreadable, http status: ${result.status}`, - type: "ERROR", - description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, - }} - /> - </Fragment>; } + return ( - <Fragment> - <NotConnectedAppMenu title="Error" /> - <NotificationCard - notification={{ - message: i18n.str`Unexpected Error`, - type: "ERROR", - description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, - }} - /> - </Fragment> + <div class="has-navbar-fixed-top"> + <ConfigContextProvider value={ctx!}> + <ApplicationReadyRoutes/> + </ConfigContextProvider> + </div> ); - } - - const SUPPORTED_VERSION = "18:0:1" - if (result.data && !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}`, - }} - /> - </Fragment> - } - - return ( - <div class="has-navbar-fixed-top"> - <ConfigContextProvider value={ctx}> - <ApplicationReadyRoutes /> - </ConfigContextProvider> - </div> - ); -} +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/ApplicationReadyRoutes.tsx b/packages/auditor-backoffice-ui/src/ApplicationReadyRoutes.tsx index 414eee39d..576792d6f 100644 --- a/packages/auditor-backoffice-ui/src/ApplicationReadyRoutes.tsx +++ b/packages/auditor-backoffice-ui/src/ApplicationReadyRoutes.tsx @@ -17,159 +17,73 @@ /** * * @author Sebastian Javier Marchano (sebasjm) + * @author Nic Eigel */ -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { createHashHistory } from "history"; -import { Fragment, VNode, h } from "preact"; -import { Route, Router, route } from "preact-router"; -import { useState } from "preact/hooks"; -import { InstanceRoutes } from "./InstanceRoutes.js"; +import {ErrorType, useTranslationContext} from "@gnu-taler/web-util/browser"; +import {createHashHistory} from "history"; +import {Fragment, VNode, h} from "preact"; +import {Route, Router, route} from "preact-router"; +import {useEffect, useErrorBoundary, useState} from "preact/hooks"; +import {InstanceRoutes} from "./InstanceRoutes.js"; import { - NotConnectedAppMenu, - NotYetReadyAppMenu, - NotificationCard, + NotConnectedAppMenu, + NotYetReadyAppMenu, + NotificationCard, } from "./components/menu/index.js"; -import { useBackendContext } from "./context/backend.js"; -import { LoginToken } from "./declaration.js"; -import { useBackendInstancesTestForAdmin } from "./hooks/backend.js"; +import { useBackendContext, useBackendTokenContext } from "./context/backend.js"; +import {Settings} from "./paths/settings/index.js"; +import { useBackendConfig, useBackendToken } from "./hooks/backend.js"; +import { Loading } from "./components/exception/loading.js"; import { LoginPage } from "./paths/login/index.js"; -import { Settings } from "./paths/settings/index.js"; -import { INSTANCE_ID_LOOKUP } from "./utils/constants.js"; /** * Check if admin against /management/instances - * @returns + * @returns */ export function ApplicationReadyRoutes(): VNode { - const { i18n } = useTranslationContext(); - const [unauthorized, setUnauthorized] = useState(false) - const { - url: backendURL, - updateToken, - alreadyTriedLogin, - } = useBackendContext(); - - function updateLoginStatus(token: LoginToken | undefined) { - updateToken(token) - setUnauthorized(false) - } - - const result = useBackendInstancesTestForAdmin(); - - const clearTokenAndGoToRoot = () => { - route("/"); - }; - const [showSettings, setShowSettings] = useState(false) - const unauthorizedAdmin = !result.loading - && !result.ok - && result.type === ErrorType.CLIENT - && result.status === HttpStatusCode.Unauthorized; - - if (!alreadyTriedLogin && !result.ok) { - return ( - <Fragment> - <NotConnectedAppMenu title="Welcome!" /> - <LoginPage onConfirm={updateToken} /> - </Fragment> - ); - } - - if (showSettings) { - return <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> - <Settings onClose={() => setShowSettings(false)} /> - </Fragment> - } - - if (result.loading) { - return <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Loading..." isPasswordOk={false} />; - } - - let admin = result.ok || unauthorizedAdmin; - let instanceNameByBackendURL: string | undefined; + const {i18n} = useTranslationContext(); + const [unauthorized, setUnauthorized] = useState(false) + const [backendToken, setToken] = useState(false) + const { url: backendURL} = useBackendContext(); + const { token } = useBackendTokenContext(); + + //TODO FIX bearer + const result = useBackendToken(); + if (result.loading) return <Loading/>; + if (!result.ok) { + return ( + <LoginPage /> + ); + } + const [showSettings, setShowSettings] = useState(false) - if (!admin) { - // * the testing against admin endpoint failed and it's not - // an authorization problem - // * merchant backend will return this SPA under the main - // endpoint or /instance/<id> endpoint - // => trying to infer the instance id - const path = new URL(backendURL).pathname; - const match = INSTANCE_ID_LOOKUP.exec(path); - if (!match || !match[1]) { - // this should be rare because - // query to /config is ok but the URL - // does not match our pattern - return ( - <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Error" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> - <NotificationCard - notification={{ - message: i18n.str`Couldn't access the server.`, - description: i18n.str`Could not infer instance id from url ${backendURL}`, - type: "ERROR", - }} - /> - {/* <ConnectionPage onConfirm={changeBackend} /> */} + if (showSettings) { + return <Fragment> + <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings"/> + <Settings onClose={() => setShowSettings(false)}/> </Fragment> - ); } - instanceNameByBackendURL = match[1]; - } - - 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(); - return ( - <Router history={history}> - <Route - default - component={DefaultMainRoute} - admin={admin} - onUnauthorized={() => setUnauthorized(true)} - onLoginPass={() => { - setUnauthorized(false) - }} - instanceNameByBackendURL={instanceNameByBackendURL} - /> - </Router> - ); + const history = createHashHistory(); + return ( + <Router history={history}> + <Route + default + component={DefaultMainRoute} + /> + </Router> + ); } function DefaultMainRoute({ - instance, - admin, - onUnauthorized, - onLoginPass, - instanceNameByBackendURL, - url, //from preact-router -}: any): VNode { - const [instanceName, setInstanceName] = useState( - instanceNameByBackendURL || instance || "default", - ); + url, //from preact-router + }: any): VNode { + //TODO + url = "app/#" + url; - return ( - <InstanceRoutes - admin={admin} - path={url} - onUnauthorized={onUnauthorized} - onLoginPass={onLoginPass} - id={instanceName} - setInstanceName={setInstanceName} - /> - ); + return ( + <InstanceRoutes + path={url} + /> + ); } diff --git a/packages/auditor-backoffice-ui/src/InstanceRoutes.tsx b/packages/auditor-backoffice-ui/src/InstanceRoutes.tsx index 163438654..83c1c9f4d 100644 --- a/packages/auditor-backoffice-ui/src/InstanceRoutes.tsx +++ b/packages/auditor-backoffice-ui/src/InstanceRoutes.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -17,469 +17,456 @@ /** * * @author Sebastian Javier Marchano (sebasjm) - * @author Nic Eigel + * @author Nicola Eigel */ +import {TranslatedString} from "@gnu-taler/taler-util"; import { - useTranslationContext, - HttpError, - ErrorType, + useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { Fragment, FunctionComponent, h, VNode } from "preact"; -import { Route, route, Router } from "preact-router"; -import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; -import { Loading } from "./components/exception/loading.js"; -import { Menu, NotificationCard } from "./components/menu/index.js"; -import { useBackendContext } from "./context/backend.js"; -import { InstanceContextProvider } from "./context/instance.js"; -import { - useBackendDefaultToken, - useBackendInstanceToken, - useSimpleLocalStorage, -} from "./hooks/index.js"; -import { useInstanceKYCDetails } from "./hooks/instance.js"; -import InstanceCreatePage from "./paths/admin/create/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 OrderCreatePage from "./paths/instance/orders/create/index.js"; -import OrderDetailsPage from "./paths/instance/orders/details/index.js"; -import OrderListPage from "./paths/instance/orders/list/index.js"; -import DepositConfirmationCreatePage from "./paths/instance/deposit_confirmations/create/index.js"; -import DepositConfirmationListPage from "./paths/instance/deposit_confirmations/list/index.js"; -import DepositConfirmationUpdatePage from "./paths/instance/deposit_confirmations/update/index.js"; -import ProductCreatePage from "./paths/instance/products/create/index.js"; -import ProductListPage from "./paths/instance/products/list/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 ReservesDetailsPage from "./paths/instance/reserves/details/index.js"; -import ReservesListPage from "./paths/instance/reserves/list/index.js"; -import TemplateCreatePage from "./paths/instance/templates/create/index.js"; -import TemplateUsePage from "./paths/instance/templates/use/index.js"; -import TemplateQrPage from "./paths/instance/templates/qr/index.js"; -import TemplateListPage from "./paths/instance/templates/list/index.js"; -import TemplateUpdatePage from "./paths/instance/templates/update/index.js"; -import WebhookCreatePage from "./paths/instance/webhooks/create/index.js"; -import WebhookListPage from "./paths/instance/webhooks/list/index.js"; -import WebhookUpdatePage from "./paths/instance/webhooks/update/index.js"; -import ValidatorCreatePage from "./paths/instance/otp_devices/create/index.js"; -import ValidatorListPage from "./paths/instance/otp_devices/list/index.js"; -import ValidatorUpdatePage from "./paths/instance/otp_devices/update/index.js"; -import TransferCreatePage from "./paths/instance/transfers/create/index.js"; -import TransferListPage from "./paths/instance/transfers/list/index.js"; -import InstanceUpdatePage, { - AdminUpdate as InstanceAdminUpdatePage, - Props as InstanceUpdatePageProps, -} from "./paths/instance/update/index.js"; -import { LoginPage } from "./paths/login/index.js"; +import {VNode, h} from "preact"; +import {Route, Router, route} from "preact-router"; +import {useEffect, useErrorBoundary, useMemo, useState} from "preact/hooks"; +import {Menu, NotificationCard} from "./components/menu/index.js"; +import {EntityContextProvider} from "./context/entity.js"; +import {Notification} from "./utils/types.js"; import NotFoundPage from "./paths/notfound/index.js"; -import { Notification } from "./utils/types.js"; -import { LoginToken, MerchantBackend } from "./declaration.js"; -import { Settings } from "./paths/settings/index.js"; -import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js"; +import {Settings} from "./paths/settings/index.js"; +import DefaultList from "./paths/default/index.js"; +import { + AuditorBackend, +} from "./declaration.js"; +import FinanceDashboard from "./paths/finance/index.js"; +import DetailsDashboard from "./paths/details/index.js"; +import OperationsDashboard from "./paths/operations/index.js"; +import SecurityDashboard from "./paths/security/index.js"; -export enum InstancePaths { - error = "/error", - settings = "/settings", - token = "/token", +export enum Paths { + error = "/error", + settings = "/settings", - inventory_list = "/inventory", - inventory_update = "/inventory/:pid/update", - inventory_new = "/inventory/new", + key_figures = "/key-figures", + critical_errors = "/critical-errors", + operating_status = "/operating-status", + detail_view = "/detail-view", - deposit_confirmation_list = "/deposit-confirmation", - deposit_confirmation_update = "/deposit-confirmation/:pid/update", - deposit_confirmation_new = "/deposit-confirmation/new", + amount_arithmethic_inconsistency_list = "/amount-arithmetic-inconsistencies", - interface = "/interface", -} + bad_sig_losses_list = "/bad-sig-losses", + + balance_list = "/balance", + + closure_lag_list = "/closure-lags", + + coin_inconsistency_list = "/coin-inconsistencies", + + denomination_key_validity_withdraw_inconsistency_list = "/denomination-key-validity-withdraw-inconsistencies", + + denomination_pending_list = "/denominations-pending", + + denomination_without_sig_list = "/denominations-without-sig", + + deposit_confirmation_list = "/deposit-confirmations", + deposit_confirmation_update = "/deposit-confirmation/:rowid/update", + + emergency_list = "/emergencies", + + emergency_by_count_list = "/emergencies-by-count", + + exchange_signkey_list = "/exchange-sign-keys", + + fee_time_inconsistency_list = "/fee-time-inconsistencies", + + historic_denomination_revenue_list = "/historic-denomination-revenues", + + misattribution_in_inconsistency_list = "/misattribution-in-inconsistencies", + + progress_list = "/progress", + + purse_not_closed_inconsistency_list = "/purse-not-closed-inconsistencies", + + purse_list = "/purses", + + refresh_hanging_list = "/refreshes-hanging", -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => { }; + reserve_balance_insufficient_inconsistency_list = "/reserve-balance-insufficient-inconsistencies", -export enum AdminPaths { - list_instances = "/instances", - new_instance = "/instance/new", - update_instance = "/instance/:id/update", + reserve_balance_summary_wrong_inconsistency_list = "/reserve-balance-summary-wrong-inconsistencies", + + reserve_in_inconsistency_list = "/reserve-in-inconsistencies", + + reserve_not_closed_inconsistency_list = "/reserve-not-closed-inconsistencies", + + reserves_list = "/reserves", + + row_inconsistency_list = "/row-inconsistencies", + + row_minor_inconsistency_list = "/row-minor-inconsistencies", + + wire_format_inconsistency_list = "/wire-format-inconsistencies", + + wire_out_inconsistency_list = "/wire-out-inconsistencies" } -export interface Props { - id: string; - admin?: boolean; - path: string; - onUnauthorized: () => void; - onLoginPass: () => void; - setInstanceName: (s: string) => void; +interface TestProps { + title: string; + endpoint: string; + entity: any; } -export function InstanceRoutes({ - id, - admin, - path, - // onUnauthorized, - onLoginPass, - setInstanceName, -}: Props): VNode { - const [defaultToken, updateDefaultToken] = useBackendDefaultToken(); - const [token, updateToken] = useBackendInstanceToken(id); - const { i18n } = useTranslationContext(); - - type GlobalNotifState = (Notification & { to: string }) | undefined; - const [globalNotification, setGlobalNotification] = - useState<GlobalNotifState>(undefined); - - const changeToken = (token?: LoginToken) => { - if (admin) { - updateToken(token); - } else { - updateDefaultToken(token); +function getInstanceTitle(path: string): TestProps { + switch (path) { + case Paths.key_figures: + return {title: `Key figures`, endpoint: "helper", entity: null}; + case Paths.critical_errors: + return {title: `Critical errors`, endpoint: "helper", entity: null}; + case Paths.operating_status: + return {title: `Operating status`, endpoint: "helper", entity: null}; + case Paths.detail_view: + return {title: `Inconsistencies`, endpoint: "helper", entity: null}; + case Paths.amount_arithmethic_inconsistency_list: + let amountArithmeticInconsistency: AuditorBackend.AmountArithmeticInconsistency.ClassAmountArithmeticInconsistency = {} as AuditorBackend.AmountArithmeticInconsistency.ClassAmountArithmeticInconsistency; + return { + title: `Amount arithmetic inconsistencies`, + endpoint: "amount-arithmetic-inconsistency", + entity: amountArithmeticInconsistency + }; + case Paths.bad_sig_losses_list: + return {title: `Bad Sig Losses`, endpoint: "bad-sig-losses", entity: null}; + case Paths.balance_list: + return {title: "Balances", endpoint: "balances", entity: null}; + case Paths.closure_lag_list: + return {title: `Closure Lags`, endpoint: "closure-lags", entity: null}; + case Paths.coin_inconsistency_list: + return {title: `Coin inconsistencies`, endpoint: "coin-inconsistency", entity: null}; + case Paths.denomination_key_validity_withdraw_inconsistency_list: + return {title: `Denomination key validity withdraw inconsistency`, endpoint: "denomination-key-validity-withdraw-inconsistency", entity: null}; + case Paths.denomination_pending_list: + return {title: `Denominations pending`, endpoint: "denomination-pending", entity: null}; + case Paths.denomination_without_sig_list: + return {title: `Denominations without sigs`, endpoint: "denominations-without-sigs", entity: null}; + case Paths.deposit_confirmation_list: + return {title: "Deposit Confirmations", endpoint: "deposit-confirmation", entity: null}; + case Paths.emergency_list: + return {title: "Emergencies", endpoint: "emergency", entity: null}; + case Paths.emergency_by_count_list: + return {title: "Emergencies by count", endpoint: "emergency-by-count", entity: null}; + case Paths.fee_time_inconsistency_list: + return {title: "Fee time inconsistencies", endpoint: "fee-time-inconsistency", entity: null}; + case Paths.historic_denomination_revenue_list: + return {title: "Historic denomination revenue", endpoint: "historic-denomination-revenue", entity: null}; + case Paths.misattribution_in_inconsistency_list: + return {title: "Misattribution in inconsistencies", endpoint: "misattribution-in-inconsistency", entity: null}; + case Paths.progress_list: + return {title: "Progress", endpoint: "progress", entity: null}; + case Paths.purse_not_closed_inconsistency_list: + return {title: "Purse not closed inconsistencies", endpoint: "purse-not-closed-inconsistencies", entity: null}; + case Paths.purse_list: + return {title: "Purses", endpoint: "purses", entity: null}; + case Paths.refresh_hanging_list: + return {title: "Refreshes hanging", endpoint: "refreshes-hanging", entity: null}; + case Paths.reserves_list: + return {title: "Reserves", endpoint: "reserves ", entity: null}; + case Paths.reserve_balance_insufficient_inconsistency_list: + return {title: "Reserve balance insufficient inconsistencies", endpoint: "reserve-balance-insufficient-inconsistency", entity: null}; + case Paths.reserve_balance_summary_wrong_inconsistency_list: + return {title: "Reserve balance summary wrong inconsistencies", endpoint: "reserve-balance-summary-wrong-inconsistency", entity: null}; + case Paths.reserve_in_inconsistency_list: + return {title: "Reserves in inconsistencies", endpoint: "reserve-in-inconsistency", entity: null}; + case Paths.reserve_not_closed_inconsistency_list: + return {title: "Reserves not closed inconsistencies", endpoint: "reserve-not-closed-inconsistency", entity: null}; + case Paths.row_inconsistency_list: + return {title: "Row inconsistencies", endpoint: "row-inconsistency", entity: null}; + case Paths.row_minor_inconsistency_list: + return {title: "Row minor inconsistencies", endpoint: "row-minor-inconsistencies", entity: null}; + case Paths.wire_format_inconsistency_list: + let wireFormatInconsistency: AuditorBackend.WireFormatInconsistency.ClassWireFormatInconsistency = {} as AuditorBackend.WireFormatInconsistency.ClassWireFormatInconsistency; + return {title: "Wire format inconsistencies", endpoint: "wire-format-inconsistency", entity: wireFormatInconsistency}; + case Paths.wire_out_inconsistency_list: + return {title: "Wire out inconsistencies", endpoint: "wire-out-inconsistency", entity: null}; + case Paths.settings: + return {title: `Settings`, endpoint: "settings", entity: null}; + default: + return {title: "", endpoint: "", entity: null}; } - onLoginPass() - }; - // const updateLoginStatus = (url: string, token?: string) => { - // changeToken(token); - // }; - - const value = useMemo( - () => ({ id, token, admin, changeToken }), - [id, token, admin], - ); - - function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) { - return function ServerErrorRedirectToImpl( - error: HttpError<MerchantBackend.ErrorDetail>, - ) { - if (error.type === ErrorType.TIMEOUT) { - setGlobalNotification({ - 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}"`, - type: "ERROR", - to, - }); - } else { - setGlobalNotification({ - message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, - description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, - details: - error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER - ? error.payload.detail - : undefined, - type: "ERROR", - to, - }); - } - return <Redirect to={to} />; - }; - } - - // const LoginPageAccessDeniend = onUnauthorized - const LoginPageAccessDenied = () => { - return <Fragment> - <NotificationCard - notification={{ - message: i18n.str`Access denied`, - description: i18n.str`Session expired or password changed.`, - type: "ERROR", - }} - /> - <LoginPage onConfirm={changeToken} /> - </Fragment> - - } - - function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) { - return function IfAdminCreateDefaultOrImpl(props?: T) { - if (admin && id === "default") { - return ( - <Fragment> - <NotificationCard - notification={{ - message: i18n.str`No 'default' instance configured yet.`, - description: i18n.str`Create a 'default' instance to begin using the merchant backoffice.`, - type: "INFO", - }} - /> - </Fragment> - ); - } - if (props) { - return <Next {...props} />; - } - return <Next />; - }; - } - - const clearTokenAndGoToRoot = () => { - route("/"); - // clear all tokens - updateToken(undefined) - updateDefaultToken(undefined) - }; - - return ( - <InstanceContextProvider value={value}> - <Menu - instance={id} - admin={admin} - onShowSettings={() => { - route(InstancePaths.interface) - }} - path={path} - onLogout={clearTokenAndGoToRoot} - setInstanceName={setInstanceName} - isPasswordOk={defaultToken !== undefined} - /> - <KycBanner /> - <NotificationCard notification={globalNotification} /> - - <Router - onChange={(e) => { - const movingOutFromNotification = - globalNotification && e.url !== globalNotification.to; - if (movingOutFromNotification) { - setGlobalNotification(undefined); - } - }} - > - {/** - * Admin pages - */} - {admin && ( - <Route - path={AdminPaths.list_instances} - component={InstanceListPage} - onCreate={() => { - route(AdminPaths.new_instance); - }} - onUpdate={(id: string): void => { - route(`/instance/${id}/update`); - }} - setInstanceName={setInstanceName} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.error)} - /> - )} - {admin && ( - <Route - path={AdminPaths.update_instance} - component={AdminInstanceUpdatePage} - onBack={() => route(AdminPaths.list_instances)} - onConfirm={() => { - route(AdminPaths.list_instances); - }} - onUpdateError={ServerErrorRedirectTo(AdminPaths.list_instances)} - onLoadError={ServerErrorRedirectTo(AdminPaths.list_instances)} - onNotFound={NotFoundPage} - /> - )} - {/** - * Update instance page - */} - <Route - path={InstancePaths.settings} - component={InstanceUpdatePage} - onBack={() => { - route(`/`); - }} - onConfirm={() => { - route(`/`); - }} - onUpdateError={noop} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.error)} - /> - {/** - * Inventory pages - */} - <Route - path={InstancePaths.inventory_list} - component={ProductListPage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - onCreate={() => { - route(InstancePaths.inventory_new); - }} - onSelect={(id: string) => { - route(InstancePaths.inventory_update.replace(":pid", id)); - }} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - /> - <Route - path={InstancePaths.inventory_update} - component={ProductUpdatePage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.inventory_list)} - onConfirm={() => { - route(InstancePaths.inventory_list); - }} - onBack={() => { - route(InstancePaths.inventory_list); - }} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - /> - <Route - path={InstancePaths.inventory_new} - component={ProductCreatePage} - onConfirm={() => { - route(InstancePaths.inventory_list); - }} - onBack={() => { - route(InstancePaths.inventory_list); - }} - /> - {/** - * Deposit confirmation pages - */} - <Route - path={InstancePaths.deposit_confirmation_list} - component={DepositConfirmationListPage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - onCreate={() => { - route(InstancePaths.deposit_confirmation_new); - }} - onSelect={(id: string) => { - route(InstancePaths.deposit_confirmation_update.replace(":pid", id)); - }} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - /> - <Route - path={InstancePaths.deposit_confirmation_update} - component={DepositConfirmationUpdatePage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.deposit_confirmation_list)} - onConfirm={() => { - route(InstancePaths.deposit_confirmation_list); - }} - onBack={() => { - route(InstancePaths.deposit_confirmation_list); - }} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - /> - <Route - path={InstancePaths.deposit_confirmation_new} - component={DepositConfirmationCreatePage} - onConfirm={() => { - route(InstancePaths.deposit_confirmation_list); - }} - onBack={() => { - route(InstancePaths.deposit_confirmation_list); - }} - /> - <Route path={InstancePaths.interface} component={Settings} /> - {/** - * Example pages - */} - <Route path="/loading" component={Loading} /> - <Route default component={NotFoundPage} /> - </Router> - </InstanceContextProvider> - ); } -export function Redirect({ to }: { to: string }): null { - useEffect(() => { - route(to, true); - }); - return null; +export interface Props { + path: string; } -function AdminInstanceUpdatePage({ - id, - ...rest -}: { id: string } & InstanceUpdatePageProps): VNode { - const [token, changeToken] = useBackendInstanceToken(id); - const updateLoginStatus = (token?: LoginToken): void => { - changeToken(token); - }; - const value = useMemo( - () => ({ id, token, admin: true, changeToken }), - [id, token], - ); - const { i18n } = useTranslationContext(); - - return ( - <InstanceContextProvider value={value}> - <InstanceAdminUpdatePage - {...rest} - instanceId={id} - onLoadError={(error: HttpError<MerchantBackend.ErrorDetail>) => { - const notif = - error.type === ErrorType.TIMEOUT - ? { - 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}'`, - type: "ERROR" as const, - } - : { - message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, - description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, - details: - error.type === ErrorType.CLIENT || - error.type === ErrorType.SERVER - ? error.payload.detail - : undefined, - type: "ERROR" as const, - }; - return ( - <Fragment> - <NotificationCard notification={notif} /> - <LoginPage onConfirm={updateLoginStatus} /> - </Fragment> - ); - }} - onUnauthorized={() => { - return ( - <Fragment> - <NotificationCard - notification={{ - message: i18n.str`Access denied`, - description: i18n.str`The access token provided is invalid`, - type: "ERROR", +export function InstanceRoutes({ + // id, + path, + // setInstanceName + }: Props): VNode { + const {i18n} = useTranslationContext(); + + type GlobalNotifState = (Notification & { to: string | undefined }) | undefined; + const [globalNotification, setGlobalNotification] = + useState<GlobalNotifState>(undefined); + + const [error] = useErrorBoundary(); + const {title, endpoint, entity} = getInstanceTitle(path.replace("app/#", "")); + + const value = useMemo( + () => ({title, path, endpoint, entity}), + [title, path, endpoint, entity], + ); + + //TODO add if needed + /*function ServerErrorRedirectTo(to: Paths) { + return function ServerErrorRedirectToImpl( + error: HttpError<AuditorBackend.ErrorDetail>, + ) { + if (error.type === ErrorType.TIMEOUT) { + setGlobalNotification({ + message: `The request to the backend take too long and was cancelled`, + description: `Diagnostic from ${error.info.url} is "${error.message}"`, + type: "ERROR", + to, + }); + } else { + setGlobalNotification({ + message: `The backend reported a problem: HTTP status #${error.status}`, + description: `Diagnostic from ${error.info.url} is '${error.message}'`, + details: + error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER + ? error.payload.detail + : undefined, + type: "ERROR", + to, + }); + } + return <Redirect to={to} />; + }; + }*/ + + + return ( + <EntityContextProvider value={value}> + <Menu + // instance={id} + path={path} + title={"Settings"} + onShowSettings={() => { + route(Paths.settings); + }}/> + <NotificationCard notification={globalNotification}/> + {error && + <NotificationCard notification={{ + message: "Internal error, please report", + type: "ERROR", + description: <pre> + {(error instanceof Error ? error.stack : String(error)) as TranslatedString} + </pre>, + }}/> + } + <Router + onChange={(e) => { + const movingOutFromNotification = + globalNotification && e.url !== globalNotification.to; + if (movingOutFromNotification) { + setGlobalNotification(undefined); + } }} - /> - <LoginPage onConfirm={updateLoginStatus} /> - </Fragment> - ); - }} - /> - </InstanceContextProvider> - ); -} + > + <Route path="/" component={Redirect} to={Paths.key_figures}/> -function KycBanner(): VNode { - const kycStatus = useInstanceKYCDetails(); - const { i18n } = useTranslationContext(); - const [settings] = useSettings(); - const today = format(new Date(), dateFormatForSettings(settings)); - const [lastHide, setLastHide] = useSimpleLocalStorage("kyc-last-hide"); - const hasBeenHidden = today === lastHide; - const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; - if (hasBeenHidden || !needsToBeShown) return <Fragment />; - return ( - <NotificationCard - notification={{ - type: "WARN", - message: "KYC verification needed", - description: ( - <div> - <p> - Some transfer are on hold until a KYC process is completed. Go to - the KYC section in the left panel for more information - </p> - <div class="buttons is-right"> - <button class="button" onClick={() => setLastHide(today)}> - <i18n.Translate>Hide for today</i18n.Translate> - </button> - </div> - </div> - ), - }} - /> - ); + <Route + path={Paths.key_figures} + component={FinanceDashboard} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.critical_errors} + component={SecurityDashboard} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.operating_status} + component={OperationsDashboard} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.detail_view} + component={DetailsDashboard} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.amount_arithmethic_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.bad_sig_losses_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.balance_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.closure_lag_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.coin_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.denomination_key_validity_withdraw_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.denomination_pending_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.denomination_without_sig_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.deposit_confirmation_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.emergency_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + <Route + path={Paths.emergency_by_count_list} + component={DefaultList} + onNotFound={NotFoundPage} + //onLoadError={ServerErrorRedirectTo(Paths.balance_list)} + /> + {<Route + path={Paths.exchange_signkey_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.fee_time_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.historic_denomination_revenue_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.misattribution_in_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.progress_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.purse_not_closed_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.purse_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.refresh_hanging_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.reserve_balance_insufficient_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.reserve_balance_summary_wrong_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.reserve_in_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.reserve_not_closed_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.reserves_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.row_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.row_minor_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.wire_out_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + {<Route + path={Paths.wire_format_inconsistency_list} + component={DefaultList} + onNotFound={NotFoundPage} + />} + <Route + path={Paths.settings} + component={Settings} + /> + + {//TODO add if needed + /** + * Example pages + */} + {/* <Route path="/loading" component={Loading}/> + <Route default component={NotFoundPage}/>*/} + </Router> + </EntityContextProvider> + ); } + +export function Redirect({to}: { to: string }): null { + useEffect(() => { + route(to, true); + }); + return null; +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx b/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx deleted file mode 100644 index b1fc33877..000000000 --- a/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - 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 { ComponentChildren, h } from "preact"; -import { LoadingModal } from "../modal/index.js"; -import { useAsync } from "../../hooks/async.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; - -type Props = { - children: ComponentChildren; - disabled: boolean; - onClick?: () => Promise<void>; - [rest: string]: any; -}; - -export function AsyncButton({ onClick, disabled, children, ...rest }: Props) { - const { isSlow, isLoading, request, cancel } = useAsync(onClick); - const { i18n } = useTranslationContext(); - if (isSlow) { - return <LoadingModal onCancel={cancel} />; - } - if (isLoading) { - return ( - <button class="button"> - <i18n.Translate>Loading...</i18n.Translate> - </button> - ); - } - - return ( - <span {...rest}> - <button class="button is-success" onClick={request} disabled={disabled}> - {children} - </button> - </span> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/exception/QR.tsx b/packages/auditor-backoffice-ui/src/components/exception/QR.tsx deleted file mode 100644 index c9340ea76..000000000 --- a/packages/auditor-backoffice-ui/src/components/exception/QR.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - 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 { h, VNode } from "preact"; -import { useEffect, useRef } from "preact/hooks"; -import qrcode from "qrcode-generator"; - -export function QR({ text }: { text: string }): VNode { - const divRef = useRef<HTMLDivElement>(null); - useEffect(() => { - const qr = qrcode(0, "L"); - qr.addData(text); - qr.make(); - if (divRef.current) { - divRef.current.innerHTML = qr.createSvgTag({ - scalable: true, - }); - } - }); - - return ( - <div - style={{ - width: "100%", - display: "flex", - flexDirection: "column", - alignItems: "center", - }} - > - <div - style={{ width: "50%", minWidth: 200, maxWidth: 300 }} - ref={divRef} - /> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/exception/loading.tsx b/packages/auditor-backoffice-ui/src/components/exception/loading.tsx index a043b81eb..11b62c124 100644 --- a/packages/auditor-backoffice-ui/src/components/exception/loading.tsx +++ b/packages/auditor-backoffice-ui/src/components/exception/loading.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -22,27 +22,27 @@ import { h, VNode } from "preact"; export function Loading(): VNode { - return ( - <div - class="columns is-centered is-vcentered" - style={{ - height: "calc(100% - 3rem)", - position: "absolute", - width: "100%", - }} - > - <Spinner /> - </div> - ); + return ( + <div + class="columns is-centered is-vcentered" + style={{ + height: "calc(100% - 3rem)", + position: "absolute", + width: "100%", + }} + > + <Spinner /> + </div> + ); } export function Spinner(): VNode { - return ( - <div class="lds-ring"> - <div /> - <div /> - <div /> - <div /> - </div> - ); + return ( + <div class="lds-ring"> + <div /> + <div /> + <div /> + <div /> + </div> + ); } diff --git a/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx b/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx deleted file mode 100644 index 4ed4c4b28..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* - 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 { InputProps, useField } from "./useField.js"; - -export interface Props<T> extends InputProps<T> { - isValid?: (e: any) => boolean; - addonBefore?: string; - toStr?: (v?: any) => string; - fromStr?: (s: string) => any; -} - -const defaultToString = (f?: any): string => f || ""; -const defaultFromString = (v: string): any => v as any; - -export function InputArray<T>({ - name, - readonly, - placeholder, - tooltip, - label, - help, - addonBefore, - isValid = () => true, - fromStr = defaultFromString, - toStr = defaultToString, -}: Props<keyof T>): VNode { - const { error: formError, value, onChange, required } = useField<T>(name); - const [localError, setLocalError] = useState<string | null>(null); - - const error = localError || formError; - - const array: any[] = (value ? value! : []) as any; - const [currentValue, setCurrentValue] = useState(""); - const { i18n } = useTranslationContext(); - - return ( - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <div class="field has-addons"> - {addonBefore && ( - <div class="control"> - <a class="button is-static">{addonBefore}</a> - </div> - )} - <p class="control is-expanded has-icons-right"> - <input - class={error ? "input is-danger" : "input"} - type="text" - placeholder={placeholder} - readonly={readonly} - disabled={readonly} - name={String(name)} - value={currentValue} - onChange={(e): void => setCurrentValue(e.currentTarget.value)} - /> - {required && ( - <span class="icon has-text-danger is-right"> - <i class="mdi mdi-alert" /> - </span> - )} - </p> - <p class="control"> - <button - class="button is-info has-tooltip-left" - disabled={!currentValue} - onClick={(): void => { - const v = fromStr(currentValue); - if (!isValid(v)) { - setLocalError( - i18n.str`The value ${v} is invalid for a payment url`, - ); - return; - } - setLocalError(null); - onChange([v, ...array] as any); - setCurrentValue(""); - }} - data-tooltip={i18n.str`add element to the list`} - > - <i18n.Translate>add</i18n.Translate> - </button> - </p> - </div> - {help} - {error && <p class="help is-danger"> {error} </p>} - {array.map((v, i) => ( - <div key={i} class="tags has-addons mt-3 mb-0"> - <span - class="tag is-medium is-info mb-0" - style={{ maxWidth: "90%" }} - > - {v} - </span> - <a - class="tag is-medium is-danger is-delete mb-0" - onClick={() => { - onChange(array.filter((f) => f !== v) as any); - setCurrentValue(toStr(v)); - }} - /> - </div> - ))} - </div> - </div> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx b/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx deleted file mode 100644 index f79e16c07..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* - 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 { InputProps, useField } from "./useField.js"; - -interface Props<T> extends InputProps<T> { - name: T; - readonly?: boolean; - expand?: boolean; - threeState?: boolean; - toBoolean?: (v?: any) => boolean | undefined; - fromBoolean?: (s: boolean | undefined) => any; -} - -const defaultToBoolean = (f?: any): boolean | undefined => f || ""; -const defaultFromBoolean = (v: boolean | undefined): any => v as any; - -export function InputBoolean<T>({ - name, - readonly, - placeholder, - tooltip, - label, - help, - threeState, - expand, - fromBoolean = defaultFromBoolean, - toBoolean = defaultToBoolean, -}: Props<keyof T>): VNode { - const { error, value, onChange } = useField<T>(name); - - const onCheckboxClick = (): void => { - const c = toBoolean(value); - if (c === false && threeState) return onChange(undefined as any); - return onChange(fromBoolean(!c)); - }; - - return ( - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class={expand ? "control is-expanded" : "control"}> - <label class="b-checkbox checkbox"> - <input - type="checkbox" - class={toBoolean(value) === undefined ? "is-indeterminate" : ""} - checked={toBoolean(value)} - placeholder={placeholder} - readonly={readonly} - name={String(name)} - disabled={readonly} - onChange={onCheckboxClick} - /> - <span class="check" /> - </label> - {help} - </p> - {error && <p class="help is-danger">{error}</p>} - </div> - </div> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx b/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx deleted file mode 100644 index a398629dc..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* - 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 { format } from "date-fns"; -import { ComponentChildren, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { DatePicker } from "../picker/DatePicker.js"; -import { InputProps, useField } from "./useField.js"; -import { dateFormatForSettings, useSettings } from "../../hooks/useSettings.js"; - -export interface Props<T> extends InputProps<T> { - readonly?: boolean; - expand?: boolean; - //FIXME: create separated components InputDate and InputTimestamp - withTimestampSupport?: boolean; - side?: ComponentChildren; -} - -export function InputDate<T>({ - name, - readonly, - label, - placeholder, - help, - tooltip, - expand, - withTimestampSupport, - side, -}: Props<keyof T>): VNode { - const [opened, setOpened] = useState(false); - const { i18n } = useTranslationContext(); - const [settings] = useSettings() - - const { error, required, value, onChange } = useField<T>(name); - - let strValue = ""; - if (!value) { - strValue = withTimestampSupport ? "unknown" : ""; - } else if (value instanceof Date) { - strValue = format(value, dateFormatForSettings(settings)); - } else if (value.t_s) { - strValue = - value.t_s === "never" - ? withTimestampSupport - ? "never" - : "" - : format(new Date(value.t_s * 1000), dateFormatForSettings(settings)); - } - - return ( - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <div class="field has-addons"> - <p - class={ - expand - ? "control is-expanded has-icons-right" - : "control has-icons-right" - } - > - <input - class="input" - type="text" - readonly - value={strValue} - placeholder={placeholder} - onClick={() => { - if (!readonly) setOpened(true); - }} - /> - {required && ( - <span class="icon has-text-danger is-right"> - <i class="mdi mdi-alert" /> - </span> - )} - {help} - </p> - <div - class="control" - onClick={() => { - if (!readonly) setOpened(true); - }} - > - <a class="button is-static"> - <span class="icon"> - <i class="mdi mdi-calendar" /> - </span> - </a> - </div> - </div> - {error && <p class="help is-danger">{error}</p>} - </div> - - {!readonly && ( - <span - data-tooltip={ - withTimestampSupport - ? i18n.str`change value to unknown date` - : i18n.str`change value to empty` - } - > - <button - class="button is-info mr-3" - onClick={() => onChange(undefined as any)} - > - <i18n.Translate>clear</i18n.Translate> - </button> - </span> - )} - {withTimestampSupport && ( - <span data-tooltip={i18n.str`change value to never`}> - <button - class="button is-info" - onClick={() => onChange({ t_s: "never" } as any)} - > - <i18n.Translate>never</i18n.Translate> - </button> - </span> - )} - {side} - </div> - <DatePicker - opened={opened} - closeFunction={() => setOpened(false)} - dateReceiver={(d) => { - if (withTimestampSupport) { - onChange({ t_s: d.getTime() / 1000 } as any); - } else { - onChange(d as any); - } - }} - /> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx b/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx deleted file mode 100644 index 7aa2703a4..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/* - 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 { formatDuration, intervalToDuration } from "date-fns"; -import { ComponentChildren, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { SimpleModal } from "../modal/index.js"; -import { DurationPicker } from "../picker/DurationPicker.js"; -import { InputProps, useField } from "./useField.js"; -import { Duration } from "@gnu-taler/taler-util"; - -export interface Props<T> extends InputProps<T> { - expand?: boolean; - readonly?: boolean; - withForever?: boolean; - side?: ComponentChildren; - withoutClear?: boolean; -} - -export function InputDuration<T>({ - name, - expand, - placeholder, - tooltip, - label, - help, - readonly, - withForever, - withoutClear, - side, -}: Props<keyof T>): VNode { - const [opened, setOpened] = useState(false); - const { i18n } = useTranslationContext(); - - const { error, required, value: anyValue, onChange } = useField<T>(name); - let strValue = ""; - const value: Duration = anyValue - if (!value) { - strValue = ""; - } else if (value.d_ms === "forever") { - strValue = i18n.str`forever`; - } else { - strValue = formatDuration( - intervalToDuration({ start: 0, end: value.d_ms }), - { - locale: { - formatDistance: (name, value) => { - switch (name) { - case "xMonths": - return i18n.str`${value}M`; - case "xYears": - return i18n.str`${value}Y`; - case "xDays": - return i18n.str`${value}d`; - case "xHours": - return i18n.str`${value}h`; - case "xMinutes": - return i18n.str`${value}min`; - case "xSeconds": - return i18n.str`${value}sec`; - } - }, - localize: { - day: () => "s", - month: () => "m", - ordinalNumber: () => "th", - dayPeriod: () => "p", - quarter: () => "w", - era: () => "e", - }, - }, - }, - ); - } - - return ( - <div class="field is-horizontal"> - <div class="field-label is-normal is-flex-grow-3"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - - <div class="is-flex-grow-3"> - <div class="field-body "> - <div class="field"> - <div class="field has-addons"> - <p class={expand ? "control is-expanded " : "control "}> - <input - class="input" - type="text" - readonly - value={strValue} - placeholder={placeholder} - onClick={() => { - if (!readonly) setOpened(true); - }} - /> - {required && ( - <span class="icon has-text-danger is-right"> - <i class="mdi mdi-alert" /> - </span> - )} - </p> - <div - class="control" - onClick={() => { - if (!readonly) setOpened(true); - }} - > - <a class="button is-static"> - <span class="icon"> - <i class="mdi mdi-clock" /> - </span> - </a> - </div> - </div> - {error && <p class="help is-danger">{error}</p>} - </div> - {withForever && ( - <span data-tooltip={i18n.str`change value to never`}> - <button - class="button is-info mr-3" - onClick={() => onChange({ d_ms: "forever" } as any)} - > - <i18n.Translate>forever</i18n.Translate> - </button> - </span> - )} - {!readonly && !withoutClear && ( - <span data-tooltip={i18n.str`change value to empty`}> - <button - class="button is-info " - onClick={() => onChange(undefined as any)} - > - <i18n.Translate>clear</i18n.Translate> - </button> - </span> - )} - {side} - </div> - <span> - {help} - </span> - </div> - - - {opened && ( - <SimpleModal onCancel={() => setOpened(false)}> - <DurationPicker - days - hours - minutes - value={!value || value.d_ms === "forever" ? 0 : value.d_ms} - onChange={(v) => { - onChange({ d_ms: v } as any); - }} - /> - </SimpleModal> - )} - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx b/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx deleted file mode 100644 index b5e0bd52b..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - 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 { ComponentChildren, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useGroupField } from "./useGroupField.js"; - -export interface Props<T> { - name: T; - children: ComponentChildren; - label: ComponentChildren; - tooltip?: ComponentChildren; - alternative?: ComponentChildren; - fixed?: boolean; - initialActive?: boolean; -} - -export function InputGroup<T>({ - name, - label, - children, - tooltip, - alternative, - fixed, - initialActive, -}: Props<keyof T>): VNode { - const [active, setActive] = useState(initialActive || fixed); - const group = useGroupField<T>(name); - - return ( - <div class="card"> - <header class="card-header"> - <p class="card-header-title"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - {group?.hasError && ( - <span class="icon has-text-danger" data-tooltip={tooltip}> - <i class="mdi mdi-alert" /> - </span> - )} - </p> - {!fixed && ( - <button - class="card-header-icon" - aria-label="more options" - onClick={(): void => setActive(!active)} - > - <span class="icon"> - {active ? ( - <i class="mdi mdi-arrow-up" /> - ) : ( - <i class="mdi mdi-arrow-down" /> - )} - </span> - </button> - )} - </header> - {active ? ( - <div class="card-content">{children}</div> - ) : alternative ? ( - <div class="card-content">{alternative}</div> - ) : undefined} - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx b/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx deleted file mode 100644 index b024e2c6b..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx +++ /dev/null @@ -1,122 +0,0 @@ -/* - 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 { ComponentChildren, h, VNode } from "preact"; -import { useRef, useState } from "preact/hooks"; -import { MAX_IMAGE_SIZE as MAX_IMAGE_UPLOAD_SIZE } from "../../utils/constants.js"; -import { InputProps, useField } from "./useField.js"; - -export interface Props<T> extends InputProps<T> { - expand?: boolean; - addonAfter?: ComponentChildren; - children?: ComponentChildren; -} - -export function InputImage<T>({ - name, - readonly, - placeholder, - tooltip, - label, - help, - children, - expand, -}: Props<keyof T>): VNode { - const { error, value, onChange } = useField<T>(name); - - const image = useRef<HTMLInputElement>(null); - const { i18n } = useTranslationContext(); - const [sizeError, setSizeError] = useState(false); - - return ( - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class={expand ? "control is-expanded" : "control"}> - {value && ( - <img - src={value} - style={{ width: 200, height: 200 }} - onClick={() => image.current?.click()} - /> - )} - <input - ref={image} - style={{ display: "none" }} - type="file" - name={String(name)} - placeholder={placeholder} - readonly={readonly} - onChange={(e) => { - const f: FileList | null = e.currentTarget.files; - if (!f || f.length != 1) { - return onChange(undefined!); - } - if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { - setSizeError(true); - return onChange(undefined!); - } - setSizeError(false); - return f[0].arrayBuffer().then((b) => { - const b64 = window.btoa( - new Uint8Array(b).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - return onChange(`data:${f[0].type};base64,${b64}` as any); - }); - }} - /> - {help} - {children} - </p> - {error && <p class="help is-danger">{error}</p>} - {sizeError && ( - <p class="help is-danger"> - <i18n.Translate>Image should be smaller than 1 MB</i18n.Translate> - </p> - )} - {!value && ( - <button class="button" onClick={() => image.current?.click()}> - <i18n.Translate>Add</i18n.Translate> - </button> - )} - {value && ( - <button class="button" onClick={() => onChange(undefined!)}> - <i18n.Translate>Remove</i18n.Translate> - </button> - )} - </div> - </div> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx b/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx deleted file mode 100644 index a2fc8113e..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - 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 { Fragment, h } from "preact"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Input } from "./Input.js"; - -export function InputLocation({ name }: { name: string }) { - const { i18n } = useTranslationContext(); - return ( - <> - <Input name={`${name}.country`} label={i18n.str`Country`} /> - <Input - name={`${name}.address_lines`} - inputType="multiline" - label={i18n.str`Address`} - toStr={(v: string[] | undefined) => (!v ? "" : v.join("\n"))} - fromStr={(v: string) => v.split("\n")} - /> - <Input - name={`${name}.building_number`} - label={i18n.str`Building number`} - /> - <Input name={`${name}.building_name`} label={i18n.str`Building name`} /> - <Input name={`${name}.street`} label={i18n.str`Street`} /> - <Input name={`${name}.post_code`} label={i18n.str`Post code`} /> - <Input name={`${name}.town_location`} label={i18n.str`Town location`} /> - <Input name={`${name}.town`} label={i18n.str`Town`} /> - <Input name={`${name}.district`} label={i18n.str`District`} /> - <Input - name={`${name}.country_subdivision`} - label={i18n.str`Country subdivision`} - /> - </> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx deleted file mode 100644 index 6e88e8f2c..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - 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 { InputArray } from "./InputArray.js"; -import { PAYTO_REGEX } from "../../utils/constants.js"; -import { InputProps } from "./useField.js"; - -export type Props<T> = InputProps<T>; - -const PAYTO_START_REGEX = /^payto:\/\//; - -export function InputPayto<T>({ - name, - readonly, - placeholder, - tooltip, - label, - help, -}: Props<keyof T>): VNode { - return ( - <InputArray<T> - name={name} - readonly={readonly} - addonBefore="payto://" - label={label} - placeholder={placeholder} - help={help} - tooltip={tooltip} - isValid={(v) => v && PAYTO_REGEX.test(v)} - toStr={(v?: string) => (!v ? "" : v.replace(PAYTO_START_REGEX, ""))} - fromStr={(v: string) => `payto://${v}`} - /> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx deleted file mode 100644 index 282e52278..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - 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 } from "preact"; -import * as tests from "@gnu-taler/web-util/testing"; -import { InputPaytoForm } from "./InputPaytoForm.js"; -import { FormProvider } from "./FormProvider.js"; -import { useState } from "preact/hooks"; - -export default { - title: "Components/Form/PayTo", - component: InputPaytoForm, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; - -export const Example = tests.createExample(() => { - const initial = { - accounts: [], - }; - const [form, updateForm] = useState<Partial<typeof initial>>(initial); - return ( - <FormProvider valueHandler={updateForm} object={form}> - <InputPaytoForm name="accounts" label="Accounts:" /> - </FormProvider> - ); -}, {}); diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx deleted file mode 100644 index 32545c89a..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx +++ /dev/null @@ -1,397 +0,0 @@ -/* - 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 { parsePaytoUri, PaytoUriGeneric, stringifyPaytoUri } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { COUNTRY_TABLE } from "../../utils/constants.js"; -import { undefinedIfEmpty } from "../../utils/table.js"; -import { FormErrors, FormProvider } from "./FormProvider.js"; -import { Input } from "./Input.js"; -import { InputGroup } from "./InputGroup.js"; -import { InputSelector } from "./InputSelector.js"; -import { InputProps, useField } from "./useField.js"; -import { useEffect, useState } from "preact/hooks"; - -export interface Props<T> extends InputProps<T> { - isValid?: (e: any) => boolean; -} - -// type Entity = PaytoUriGeneric -// https://datatracker.ietf.org/doc/html/rfc8905 -type Entity = { - // iban, bitcoin, x-taler-bank. it defined the format - target: string; - // path1 if the first field to be used - path1?: string; - // path2 if the second field to be used, optional - path2?: string; - // params of the payto uri - params: { - "receiver-name"?: string; - sender?: string; - message?: string; - amount?: string; - instruction?: string; - [name: string]: string | undefined; - }; -}; - -function isEthereumAddress(address: string) { - if (!/^(0x)?[0-9a-f]{40}$/i.test(address)) { - return false; - } else if ( - /^(0x|0X)?[0-9a-f]{40}$/.test(address) || - /^(0x|0X)?[0-9A-F]{40}$/.test(address) - ) { - return true; - } - return checkAddressChecksum(address); -} - -function checkAddressChecksum(address: string) { - //TODO implement ethereum checksum - return true; -} - -function validateBitcoin( - addr: string, - i18n: ReturnType<typeof useTranslationContext>["i18n"], -): string | undefined { - try { - const valid = /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/.test(addr); - if (valid) return undefined; - } catch (e) { - console.log(e); - } - return i18n.str`This is not a valid bitcoin address.`; -} - -function validateEthereum( - addr: string, - i18n: ReturnType<typeof useTranslationContext>["i18n"], -): string | undefined { - try { - const valid = isEthereumAddress(addr); - if (valid) return undefined; - } catch (e) { - console.log(e); - } - return i18n.str`This is not a valid Ethereum address.`; -} - -/** - * An IBAN is validated by converting it into an integer and performing a - * basic mod-97 operation (as described in ISO 7064) on it. - * If the IBAN is valid, the remainder equals 1. - * - * The algorithm of IBAN validation is as follows: - * 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid - * 2.- Move the four initial characters to the end of the string - * 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35 - * 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97 - * - * If the remainder is 1, the check digit test is passed and the IBAN might be valid. - * - */ -function validateIBAN( - iban: string, - i18n: ReturnType<typeof useTranslationContext>["i18n"], -): string | undefined { - // Check total length - if (iban.length < 4) - return i18n.str`IBAN numbers usually have more that 4 digits`; - if (iban.length > 34) - return i18n.str`IBAN numbers usually have less that 34 digits`; - - const A_code = "A".charCodeAt(0); - const Z_code = "Z".charCodeAt(0); - const IBAN = iban.toUpperCase(); - // check supported country - const code = IBAN.substr(0, 2); - const found = code in COUNTRY_TABLE; - if (!found) return i18n.str`IBAN country code not found`; - - // 2.- Move the four initial characters to the end of the string - const step2 = IBAN.substr(4) + iban.substr(0, 4); - const step3 = Array.from(step2) - .map((letter) => { - const code = letter.charCodeAt(0); - if (code < A_code || code > Z_code) return letter; - return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`; - }) - .join(""); - - function calculate_iban_checksum(str: string): number { - const numberStr = str.substr(0, 5); - const rest = str.substr(5); - const number = parseInt(numberStr, 10); - const result = number % 97; - if (rest.length > 0) { - return calculate_iban_checksum(`${result}${rest}`); - } - return result; - } - - const checksum = calculate_iban_checksum(step3); - if (checksum !== 1) - return i18n.str`IBAN number is not valid, checksum is wrong`; - return undefined; -} - -// const targets = ['ach', 'bic', 'iban', 'upi', 'bitcoin', 'ilp', 'void', 'x-taler-bank'] -const targets = [ - "Choose one...", - "iban", - "x-taler-bank", - "bitcoin", - "ethereum", -]; -const noTargetValue = targets[0]; -const defaultTarget: Entity = { - target: noTargetValue, - params: {}, -}; - -export function InputPaytoForm<T>({ - name, - readonly, - label, - tooltip, -}: Props<keyof T>): VNode { - const { value: initialValueStr, onChange } = useField<T>(name); - - const initialPayto = parsePaytoUri(initialValueStr ?? "") - const paths = !initialPayto ? [] : initialPayto.targetPath.split("/") - const initialPath1 = paths.length >= 1 ? paths[0] : undefined; - const initialPath2 = paths.length >= 2 ? paths[1] : undefined; - const initial: Entity = initialPayto === undefined ? defaultTarget : { - target: initialPayto.targetType, - params: initialPayto.params, - path1: initialPath1, - path2: initialPath2, - } - const [value, setValue] = useState<Partial<Entity>>(initial) - - const { i18n } = useTranslationContext(); - - const errors: FormErrors<Entity> = { - target: - value.target === noTargetValue - ? i18n.str`required` - : undefined, - path1: !value.path1 - ? i18n.str`required` - : value.target === "iban" - ? validateIBAN(value.path1, i18n) - : value.target === "bitcoin" - ? validateBitcoin(value.path1, i18n) - : value.target === "ethereum" - ? validateEthereum(value.path1, i18n) - : undefined, - path2: - value.target === "x-taler-bank" - ? !value.path2 - ? i18n.str`required` - : undefined - : undefined, - params: undefinedIfEmpty({ - "receiver-name": !value.params?.["receiver-name"] - ? i18n.str`required` - : undefined, - }), - }; - - const hasErrors = Object.keys(errors).some( - (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 accounts: MerchantBackend.BankAccounts.AccountAddDetails[] = paytos; - // // const alreadyExists = - // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1; - // // if (!alreadyExists) { - // const newValue: MerchantBackend.BankAccounts.AccountAddDetails = { - // payto_uri: paytoURL, - // }; - // if (value.auth) { - // if (value.auth.url) { - // newValue.credit_facade_url = value.auth.url; - // } - // if (value.auth.type === "none") { - // newValue.credit_facade_credentials = { - // type: "none", - // }; - // } - // if (value.auth.type === "basic") { - // newValue.credit_facade_credentials = { - // type: "basic", - // username: value.auth.username ?? "", - // password: value.auth.password ?? "", - // }; - // } - // } - // onChange(newValue as any); - // // } - // // valueHandler(defaultTarget); - // }, [value]); - - //FIXME: translating plural singular - return ( - <InputGroup name="payto" label={label} fixed tooltip={tooltip}> - <FormProvider<Entity> - name="tax" - errors={errors} - object={value} - valueHandler={setValue} - > - <InputSelector<Entity> - name="target" - label={i18n.str`Account type`} - tooltip={i18n.str`Method to use for wire transfer`} - values={targets} - readonly={readonly} - toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)} - /> - - {value.target === "ach" && ( - <Fragment> - <Input<Entity> - name="path1" - label={i18n.str`Routing`} - readonly={readonly} - tooltip={i18n.str`Routing number.`} - /> - <Input<Entity> - name="path2" - label={i18n.str`Account`} - readonly={readonly} - tooltip={i18n.str`Account number.`} - /> - </Fragment> - )} - {value.target === "bic" && ( - <Fragment> - <Input<Entity> - name="path1" - label={i18n.str`Code`} - readonly={readonly} - tooltip={i18n.str`Business Identifier Code.`} - /> - </Fragment> - )} - {value.target === "iban" && ( - <Fragment> - <Input<Entity> - name="path1" - label={i18n.str`IBAN`} - tooltip={i18n.str`International Bank Account Number.`} - readonly={readonly} - placeholder="DE1231231231" - inputExtra={{ style: { textTransform: "uppercase" } }} - /> - </Fragment> - )} - {value.target === "upi" && ( - <Fragment> - <Input<Entity> - name="path1" - readonly={readonly} - label={i18n.str`Account`} - tooltip={i18n.str`Unified Payment Interface.`} - /> - </Fragment> - )} - {value.target === "bitcoin" && ( - <Fragment> - <Input<Entity> - name="path1" - readonly={readonly} - label={i18n.str`Address`} - tooltip={i18n.str`Bitcoin protocol.`} - /> - </Fragment> - )} - {value.target === "ethereum" && ( - <Fragment> - <Input<Entity> - name="path1" - readonly={readonly} - label={i18n.str`Address`} - tooltip={i18n.str`Ethereum protocol.`} - /> - </Fragment> - )} - {value.target === "ilp" && ( - <Fragment> - <Input<Entity> - name="path1" - readonly={readonly} - label={i18n.str`Address`} - tooltip={i18n.str`Interledger protocol.`} - /> - </Fragment> - )} - {value.target === "void" && <Fragment />} - {value.target === "x-taler-bank" && ( - <Fragment> - <Input<Entity> - name="path1" - readonly={readonly} - label={i18n.str`Host`} - tooltip={i18n.str`Bank host.`} - /> - <Input<Entity> - name="path2" - readonly={readonly} - label={i18n.str`Account`} - tooltip={i18n.str`Bank account.`} - /> - </Fragment> - )} - - {/** - * Show additional fields apart from the payto - */} - {value.target !== noTargetValue && ( - <Fragment> - <Input - name="params.receiver-name" - readonly={readonly} - label={i18n.str`Owner's name`} - tooltip={i18n.str`Legal name of the person holding the account.`} - /> - </Fragment> - )} - - </FormProvider> - </InputGroup> - ); -} - diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx deleted file mode 100644 index be5800d14..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx +++ /dev/null @@ -1,204 +0,0 @@ -/* - 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 emptyImage from "../../assets/empty.png"; -import { FormErrors, FormProvider } from "./FormProvider.js"; -import { InputWithAddon } from "./InputWithAddon.js"; -import { TranslatedString } from "@gnu-taler/taler-util"; - -type Entity = { - id: string, - description: string; - image?: string; - extra?: string; -}; - -export interface Props<T extends Entity> { - selected?: T; - onChange: (p?: T) => void; - label: TranslatedString; - list: T[]; - withImage?: boolean; -} - -interface Search { - name: string; -} - -export function InputSearchOnList<T extends Entity>({ - selected, - onChange, - label, - list, - withImage, -}: Props<T>): VNode { - const [nameForm, setNameForm] = useState<Partial<Search>>({ - name: "", - }); - - const errors: FormErrors<Search> = { - name: undefined, - }; - const { i18n } = useTranslationContext(); - - if (selected) { - return ( - <article class="media"> - {withImage && - <figure class="media-left"> - <p class="image is-128x128"> - <img src={selected.image ? selected.image : emptyImage} /> - </p> - </figure> - } - <div class="media-content"> - <div class="content"> - <p class="media-meta"> - <i18n.Translate>ID</i18n.Translate>: <b>{selected.id}</b> - </p> - <p> - <i18n.Translate>Description</i18n.Translate>:{" "} - {selected.description} - </p> - <div class="buttons is-right mt-5"> - <button - class="button is-info" - onClick={() => onChange(undefined)} - > - clear - </button> - </div> - </div> - </div> - </article> - ); - } - - return ( - <FormProvider<Search> - errors={errors} - object={nameForm} - valueHandler={setNameForm} - > - <InputWithAddon<Search> - name="name" - label={label} - tooltip={i18n.str`enter description or id`} - addonAfter={ - <span class="icon"> - <i class="mdi mdi-magnify" /> - </span> - } - > - <div> - <DropdownList - name={nameForm.name} - list={list} - onSelect={(p) => { - setNameForm({ name: "" }); - onChange(p); - }} - withImage={!!withImage} - /> - </div> - </InputWithAddon> - </FormProvider> - ); -} - -interface DropdownListProps<T extends Entity> { - name?: string; - onSelect: (p: T) => void; - list: T[]; - withImage: boolean; -} - -function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) { - const { i18n } = useTranslationContext(); - if (!name) { - /* FIXME - this BR is added to occupy the space that will be added when the - dropdown appears - */ - return ( - <div> - <br /> - </div> - ); - } - const filtered = list.filter( - (p) => p.id.includes(name) || p.description.includes(name), - ); - - return ( - <div class="dropdown is-active"> - <div - class="dropdown-menu" - id="dropdown-menu" - role="menu" - style={{ minWidth: "20rem" }} - > - <div class="dropdown-content"> - {!filtered.length ? ( - <div class="dropdown-item"> - <i18n.Translate> - no match found with that description or id - </i18n.Translate> - </div> - ) : ( - filtered.map((p) => ( - <div - key={p.id} - class="dropdown-item" - onClick={() => onSelect(p)} - style={{ cursor: "pointer" }} - > - <article class="media"> - {withImage && - <div class="media-left"> - <div class="image" style={{ minWidth: 64 }}> - <img - src={p.image ? p.image : emptyImage} - style={{ width: 64, height: 64 }} - /> - </div> - </div> - } - <div class="media-content"> - <div class="content"> - <p> - <strong>{p.id}</strong> {p.extra !== undefined ? <small>{p.extra}</small> : undefined} - <br /> - {p.description} - </p> - </div> - </div> - </article> - </div> - )) - )} - </div> - </div> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx deleted file mode 100644 index 12ce6c6aa..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - 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 { useState } from "preact/hooks"; -import { FormProvider } from "./FormProvider.js"; -import { InputSecured } from "./InputSecured.js"; - -export default { - title: "Components/Form/InputSecured", - component: InputSecured, -}; - -type T = { auth_token: string | null }; - -export const InitialValueEmpty = (): VNode => { - const [state, setState] = useState<Partial<T>>({ auth_token: "" }); - return ( - <FormProvider<T> object={state} errors={{}} valueHandler={setState}> - Initial value: '' - <InputSecured<T> name="auth_token" label="Access token" /> - </FormProvider> - ); -}; - -export const InitialValueToken = (): VNode => { - const [state, setState] = useState<Partial<T>>({ auth_token: "token" }); - return ( - <FormProvider<T> object={state} errors={{}} valueHandler={setState}> - <InputSecured<T> name="auth_token" label="Access token" /> - </FormProvider> - ); -}; - -export const InitialValueNull = (): VNode => { - const [state, setState] = useState<Partial<T>>({ auth_token: null }); - return ( - <FormProvider<T> object={state} errors={{}} valueHandler={setState}> - Initial value: '' - <InputSecured<T> name="auth_token" label="Access token" /> - </FormProvider> - ); -}; diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx deleted file mode 100644 index 9d1a3ab8e..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/* - 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 { InputProps, useField } from "./useField.js"; - -export type Props<T> = InputProps<T>; - -const TokenStatus = ({ prev, post }: any) => { - const { i18n } = useTranslationContext(); - if ( - (prev === undefined || prev === null) && - (post === undefined || post === null) - ) - return null; - return prev === post ? null : post === null ? ( - <span class="tag is-danger is-align-self-center ml-2"> - <i18n.Translate>Deleting</i18n.Translate> - </span> - ) : ( - <span class="tag is-warning is-align-self-center ml-2"> - <i18n.Translate>Changing</i18n.Translate> - </span> - ); -}; - -export function InputSecured<T>({ - name, - readonly, - placeholder, - tooltip, - label, - help, -}: Props<keyof T>): VNode { - const { error, value, initial, onChange, toStr, fromStr } = useField<T>(name); - - const [active, setActive] = useState(false); - const [newValue, setNuewValue] = useState(""); - - const { i18n } = useTranslationContext(); - - return ( - <Fragment> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - <div class="field-body is-flex-grow-3"> - {!active ? ( - <Fragment> - <div class="field has-addons"> - <button - class="button" - onClick={(): void => { - setActive(!active); - }} - > - <div class="icon is-left"> - <i class="mdi mdi-lock-reset" /> - </div> - <span> - <i18n.Translate>Manage access token</i18n.Translate> - </span> - </button> - <TokenStatus prev={initial} post={value} /> - </div> - </Fragment> - ) : ( - <Fragment> - <div class="field has-addons"> - <div class="control"> - <a class="button is-static">secret-token:</a> - </div> - <div class="control is-expanded"> - <input - class="input" - type="text" - placeholder={placeholder} - readonly={readonly || !active} - disabled={readonly || !active} - name={String(name)} - value={newValue} - onInput={(e): void => { - setNuewValue(e.currentTarget.value); - }} - /> - {help} - </div> - <div class="control"> - <button - class="button is-info" - disabled={fromStr(newValue) === value} - onClick={(): void => { - onChange(fromStr(newValue)); - setActive(!active); - setNuewValue(""); - }} - > - <div class="icon is-left"> - <i class="mdi mdi-lock-outline" /> - </div> - <span> - <i18n.Translate>Update</i18n.Translate> - </span> - </button> - </div> - </div> - </Fragment> - )} - {error ? <p class="help is-danger">{error}</p> : null} - </div> - </div> - {active && ( - <div class="field is-horizontal"> - <div class="field-body is-flex-grow-3"> - <div class="level" style={{ width: "100%" }}> - <div class="level-right is-flex-grow-1"> - <div class="level-item"> - <button - class="button is-danger" - disabled={null === value || undefined === value} - onClick={(): void => { - onChange(null!); - setActive(!active); - setNuewValue(""); - }} - > - <div class="icon is-left"> - <i class="mdi mdi-lock-open-variant" /> - </div> - <span> - <i18n.Translate>Remove</i18n.Translate> - </span> - </button> - </div> - <div class="level-item"> - <button - class="button " - onClick={(): void => { - onChange(initial!); - setActive(!active); - setNuewValue(""); - }} - > - <div class="icon is-left"> - <i class="mdi mdi-lock-open-variant" /> - </div> - <span> - <i18n.Translate>Cancel</i18n.Translate> - </span> - </button> - </div> - </div> - </div> - </div> - </div> - )} - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx deleted file mode 100644 index 668c65ea7..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* - 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 { addDays } from "date-fns"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { FormProvider } from "./FormProvider.js"; -import { InputStock, Stock } from "./InputStock.js"; - -export default { - title: "Components/Form/InputStock", - component: InputStock, -}; - -type T = { stock?: Stock }; - -export const CreateStockEmpty = () => { - const [state, setState] = useState<Partial<T>>({}); - return ( - <FormProvider<T> - name="product" - object={state} - errors={{}} - valueHandler={setState} - > - <InputStock<T> name="stock" label="Stock" /> - <div> - <pre>{JSON.stringify(state, undefined, 2)}</pre> - </div> - </FormProvider> - ); -}; - -export const CreateStockUnknownRestock = () => { - const [state, setState] = useState<Partial<T>>({ - stock: { - current: 10, - lost: 0, - sold: 0, - }, - }); - return ( - <FormProvider<T> - name="product" - object={state} - errors={{}} - valueHandler={setState} - > - <InputStock<T> name="stock" label="Stock" /> - <div> - <pre>{JSON.stringify(state, undefined, 2)}</pre> - </div> - </FormProvider> - ); -}; - -export const CreateStockNoRestock = () => { - const [state, setState] = useState<Partial<T>>({ - stock: { - current: 10, - lost: 0, - sold: 0, - nextRestock: { t_s: "never" }, - }, - }); - return ( - <FormProvider<T> - name="product" - object={state} - errors={{}} - valueHandler={setState} - > - <InputStock<T> name="stock" label="Stock" /> - <div> - <pre>{JSON.stringify(state, undefined, 2)}</pre> - </div> - </FormProvider> - ); -}; - -export const CreateStockWithRestock = () => { - const [state, setState] = useState<Partial<T>>({ - stock: { - current: 15, - lost: 0, - sold: 0, - nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 }, - }, - }); - return ( - <FormProvider<T> - name="product" - object={state} - errors={{}} - valueHandler={setState} - > - <InputStock<T> name="stock" label="Stock" /> - <div> - <pre>{JSON.stringify(state, undefined, 2)}</pre> - </div> - </FormProvider> - ); -}; - -export const UpdatingProductWithManagedStock = () => { - const [state, setState] = useState<Partial<T>>({ - stock: { - current: 100, - lost: 0, - sold: 0, - nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 }, - }, - }); - return ( - <FormProvider<T> - name="product" - object={state} - errors={{}} - valueHandler={setState} - > - <InputStock<T> name="stock" label="Stock" alreadyExist /> - <div> - <pre>{JSON.stringify(state, undefined, 2)}</pre> - </div> - </FormProvider> - ); -}; - -export const UpdatingProductWithInfiniteStock = () => { - const [state, setState] = useState<Partial<T>>({}); - return ( - <FormProvider<T> - name="product" - object={state} - errors={{}} - valueHandler={setState} - > - <InputStock<T> name="stock" label="Stock" alreadyExist /> - <div> - <pre>{JSON.stringify(state, undefined, 2)}</pre> - </div> - </FormProvider> - ); -}; diff --git a/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx b/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx deleted file mode 100644 index 1d18685c5..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/* - 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 } from "preact"; -import { useLayoutEffect, useState } from "preact/hooks"; -import { MerchantBackend, Timestamp } from "../../declaration.js"; -import { FormErrors, FormProvider } from "./FormProvider.js"; -import { InputDate } from "./InputDate.js"; -import { InputGroup } from "./InputGroup.js"; -import { InputLocation } from "./InputLocation.js"; -import { InputNumber } from "./InputNumber.js"; -import { InputProps, useField } from "./useField.js"; - -export interface Props<T> extends InputProps<T> { - alreadyExist?: boolean; -} - -type Entity = Stock; - -export interface Stock { - current: number; - lost: number; - sold: number; - address?: MerchantBackend.Location; - nextRestock?: Timestamp; -} - -interface StockDelta { - incoming: number; - lost: number; -} - -export function InputStock<T>({ - name, - tooltip, - label, - alreadyExist, -}: Props<keyof T>) { - const { error, value, onChange } = useField<T>(name); - - const [errors, setErrors] = useState<FormErrors<Entity>>({}); - - const [formValue, valueHandler] = useState<Partial<Entity>>(value); - const [addedStock, setAddedStock] = useState<StockDelta>({ - incoming: 0, - lost: 0, - }); - const { i18n } = useTranslationContext(); - - useLayoutEffect(() => { - if (!formValue) { - onChange(undefined as any); - } else { - onChange({ - ...formValue, - current: (formValue?.current || 0) + addedStock.incoming, - lost: (formValue?.lost || 0) + addedStock.lost, - } as any); - } - }, [formValue, addedStock]); - - if (!formValue) { - return ( - <Fragment> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field has-addons"> - {!alreadyExist ? ( - <button - class="button" - data-tooltip={i18n.str`click here to configure the stock of the product, leave it as is and the backend will not control stock`} - onClick={(): void => { - valueHandler({ - current: 0, - lost: 0, - sold: 0, - } as Stock as any); - }} - > - <span> - <i18n.Translate>Manage stock</i18n.Translate> - </span> - </button> - ) : ( - <button - class="button" - data-tooltip={i18n.str`this product has been configured without stock control`} - disabled - > - <span> - <i18n.Translate>Infinite</i18n.Translate> - </span> - </button> - )} - </div> - </div> - </div> - </Fragment> - ); - } - - const currentStock = - (formValue.current || 0) - (formValue.lost || 0) - (formValue.sold || 0); - - const stockAddedErrors: FormErrors<typeof addedStock> = { - lost: - currentStock + addedStock.incoming < addedStock.lost - ? i18n.str`lost cannot be greater than current and incoming (max ${ - currentStock + addedStock.incoming - })` - : undefined, - }; - - // const stockUpdateDescription = stockAddedErrors.lost ? '' : ( - // !!addedStock.incoming || !!addedStock.lost ? - // i18n.str`current stock will change from ${currentStock} to ${currentStock + addedStock.incoming - addedStock.lost}` : - // i18n.str`current stock will stay at ${currentStock}` - // ) - - return ( - <Fragment> - <div class="card"> - <header class="card-header"> - <p class="card-header-title"> - {label} - {tooltip && ( - <span class="icon" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </p> - </header> - <div class="card-content"> - <FormProvider<Entity> - name="stock" - errors={errors} - object={formValue} - valueHandler={valueHandler} - > - {alreadyExist ? ( - <Fragment> - <FormProvider - name="added" - errors={stockAddedErrors} - object={addedStock} - valueHandler={setAddedStock as any} - > - <InputNumber name="incoming" label={i18n.str`Incoming`} /> - <InputNumber name="lost" label={i18n.str`Lost`} /> - </FormProvider> - - {/* <div class="field is-horizontal"> - <div class="field-label is-normal" /> - <div class="field-body is-flex-grow-3"> - <div class="field"> - {stockUpdateDescription} - </div> - </div> - </div> */} - </Fragment> - ) : ( - <InputNumber<Entity> - name="current" - label={i18n.str`Current`} - side={ - <button - class="button is-danger" - data-tooltip={i18n.str`remove stock control for this product`} - onClick={(): void => { - valueHandler(undefined as any); - }} - > - <span> - <i18n.Translate>without stock</i18n.Translate> - </span> - </button> - } - /> - )} - - <InputDate<Entity> - name="nextRestock" - label={i18n.str`Next restock`} - withTimestampSupport - /> - - <InputGroup<Entity> name="address" label={i18n.str`Warehouse address`}> - <InputLocation name="address" /> - </InputGroup> - </FormProvider> - </div> - </div> - </Fragment> - ); -} -// ( diff --git a/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx b/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx deleted file mode 100644 index 2701768aa..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - 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 { InputProps, useField } from "./useField.js"; - -interface Props<T> extends InputProps<T> { - readonly?: boolean; - expand?: boolean; - values: any[]; - toStr?: (v?: any) => string; - fromStr?: (s: string) => any; -} - -const defaultToString = (f?: any): string => f || ""; -const defaultFromString = (v: string): any => v as any; - -export function InputTab<T>({ - name, - readonly, - expand, - placeholder, - tooltip, - label, - help, - values, - fromStr = defaultFromString, - toStr = defaultToString, -}: Props<keyof T>): VNode { - const { error, value, onChange, required } = useField<T>(name); - return ( - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field has-icons-right"> - <p class={expand ? "control is-expanded " : "control "}> - <div class="tabs is-toggle is-fullwidth is-small"> - <ul> - {values.map((v, i) => { - return ( - <li key={i} class={value === v ? "is-active" : ""} - onClick={(e) => { onChange(v) }} - > - <a style={{ cursor: "initial" }}> - <span>{toStr(v)}</span> - </a> - </li> - ); - })} - </ul> - </div> - {help} - </p> - {required && ( - <span class="icon has-text-danger is-right" style={{ height: "2.5em" }}> - <i class="mdi mdi-alert" /> - </span> - )} - {error && <p class="help is-danger">{error}</p>} - </div> - </div> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx b/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx deleted file mode 100644 index b5722e4ec..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/* - 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 { useCallback, useState } from "preact/hooks"; -import * as yup from "yup"; -import { MerchantBackend } from "../../declaration.js"; -import { TaxSchema as schema } from "../../schemas/index.js"; -import { FormErrors, FormProvider } from "./FormProvider.js"; -import { Input } from "./Input.js"; -import { InputGroup } from "./InputGroup.js"; -import { InputProps, useField } from "./useField.js"; - -export interface Props<T> extends InputProps<T> { - isValid?: (e: any) => boolean; -} - -type Entity = MerchantBackend.Tax; -export function InputTaxes<T>({ - name, - readonly, - label, -}: Props<keyof T>): VNode { - const { value: taxes, onChange } = useField<T>(name); - - const [value, valueHandler] = useState<Partial<Entity>>({}); - // const [errors, setErrors] = useState<FormErrors<Entity>>({}) - - let errors: FormErrors<Entity> = {}; - - try { - schema.validateSync(value, { abortEarly: false }); - } catch (err) { - if (err instanceof yup.ValidationError) { - const yupErrors = err.inner as yup.ValidationError[]; - errors = yupErrors.reduce( - (prev, cur) => - !cur.path ? prev : { ...prev, [cur.path]: cur.message }, - {}, - ); - } - } - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submit = useCallback((): void => { - onChange([value as any, ...taxes] as any); - valueHandler({}); - }, [value]); - - const { i18n } = useTranslationContext(); - - //FIXME: translating plural singular - return ( - <InputGroup - name="tax" - label={label} - alternative={ - taxes.length > 0 && ( - <p>This product has {taxes.length} applicable taxes configured.</p> - ) - } - > - <FormProvider<Entity> - name="tax" - errors={errors} - object={value} - valueHandler={valueHandler} - > - <div class="field is-horizontal"> - <div class="field-label is-normal" /> - <div class="field-body" style={{ display: "block" }}> - {taxes.map((v: any, 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%" }} - > - <b>{v.tax}</b>: {v.name} - </span> - <a - class="tag is-medium is-danger is-delete mb-0" - onClick={() => { - onChange(taxes.filter((f: any) => f !== v) as any); - valueHandler(v); - }} - /> - </div> - ))} - {!taxes.length && i18n.str`No taxes configured for this product.`} - </div> - </div> - - <Input<Entity> - name="tax" - label={i18n.str`Amount`} - tooltip={i18n.str`Taxes can be in currencies that differ from the main currency used by the merchant.`} - > - <i18n.Translate> - Enter currency and value separated with a colon, e.g. - "USD:2.3". - </i18n.Translate> - </Input> - - <Input<Entity> - name="name" - label={i18n.str`Description`} - tooltip={i18n.str`Legal name of the tax, e.g. VAT or import duties.`} - /> - - <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> - </InputGroup> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx b/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx deleted file mode 100644 index a0e1d6ae4..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { TranslatedString } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; - -export function JumpToElementById({ testIfExist, onSelect, placeholder, description }: { placeholder: TranslatedString, description: TranslatedString, testIfExist: (id: string) => Promise<any>, onSelect: (id: string) => void }): VNode { - const { i18n } = useTranslationContext() - - const [error, setError] = useState<string | undefined>( - undefined, - ); - - const [id, setId] = useState<string>() - async function check(currentId: string | undefined): Promise<void> { - if (!currentId) { - setError(i18n.str`missing id`); - return; - } - try { - await testIfExist(currentId); - onSelect(currentId); - setError(undefined); - } catch { - setError(i18n.str`not found`); - } - } - - return <div class="level"> - <div class="level-left"> - <div class="level-item"> - <div class="field has-addons"> - <div class="control"> - <input - class={error ? "input is-danger" : "input"} - type="text" - value={id ?? ""} - onChange={(e) => setId(e.currentTarget.value)} - placeholder={placeholder} - /> - {error && <p class="help is-danger">{error}</p>} - </div> - <span - class="has-tooltip-bottom" - data-tooltip={description} - > - <button - class="button" - onClick={(e) => check(id)} - > - <span class="icon"> - <i class="mdi mdi-arrow-right" /> - </span> - </button> - </span> - </div> - </div> - </div> - </div> -} diff --git a/packages/auditor-backoffice-ui/src/components/form/TextField.tsx b/packages/auditor-backoffice-ui/src/components/form/TextField.tsx deleted file mode 100644 index 03f36dcbb..000000000 --- a/packages/auditor-backoffice-ui/src/components/form/TextField.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - 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 { ComponentChildren, h, VNode } from "preact"; -import { useField, InputProps } from "./useField.js"; - -interface Props<T> extends InputProps<T> { - inputType?: "text" | "number" | "multiline" | "password"; - expand?: boolean; - side?: ComponentChildren; - children: ComponentChildren; -} - -export function TextField<T>({ - name, - tooltip, - label, - expand, - help, - children, - side, -}: Props<keyof T>): VNode { - const { error } = useField<T>(name); - return ( - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - {label} - {tooltip && ( - <span class="icon has-tooltip-right" data-tooltip={tooltip}> - <i class="mdi mdi-information" /> - </span> - )} - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p - class={ - expand - ? "control is-expanded has-icons-right" - : "control has-icons-right" - } - > - {children} - {help} - </p> - {error && <p class="help is-danger">{error}</p>} - </div> - {side} - </div> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx b/packages/auditor-backoffice-ui/src/components/forms/FormProvider.tsx index 0d53c4d08..a5f3c1d2f 100644 --- a/packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx +++ b/packages/auditor-backoffice-ui/src/components/forms/FormProvider.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/form/Input.tsx b/packages/auditor-backoffice-ui/src/components/forms/Input.tsx index c1ddcb064..899061c35 100644 --- a/packages/auditor-backoffice-ui/src/components/form/Input.tsx +++ b/packages/auditor-backoffice-ui/src/components/forms/Input.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/auditor-backoffice-ui/src/components/forms/InputCurrency.tsx index b02354d7c..c1359e641 100644 --- a/packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx +++ b/packages/auditor-backoffice-ui/src/components/forms/InputCurrency.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx b/packages/auditor-backoffice-ui/src/components/forms/InputNumber.tsx index 3b5df1474..10b28cd93 100644 --- a/packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx +++ b/packages/auditor-backoffice-ui/src/components/forms/InputNumber.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx b/packages/auditor-backoffice-ui/src/components/forms/InputSelector.tsx index a8dad5d89..f567f7247 100644 --- a/packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx +++ b/packages/auditor-backoffice-ui/src/components/forms/InputSelector.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx b/packages/auditor-backoffice-ui/src/components/forms/InputToggle.tsx index f95dfcd05..89b815b4b 100644 --- a/packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx +++ b/packages/auditor-backoffice-ui/src/components/forms/InputToggle.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx b/packages/auditor-backoffice-ui/src/components/forms/InputWithAddon.tsx index e9fd88770..b8cd4c2d2 100644 --- a/packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx +++ b/packages/auditor-backoffice-ui/src/components/forms/InputWithAddon.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/form/useField.tsx b/packages/auditor-backoffice-ui/src/components/forms/useField.tsx index c7559faae..49bba4984 100644 --- a/packages/auditor-backoffice-ui/src/components/form/useField.tsx +++ b/packages/auditor-backoffice-ui/src/components/forms/useField.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/index.stories.ts b/packages/auditor-backoffice-ui/src/components/index.stories.ts deleted file mode 100644 index c57ddab14..000000000 --- a/packages/auditor-backoffice-ui/src/components/index.stories.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - 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/> - */ - -export * as payto from "./form/InputPaytoForm.stories.js"; diff --git a/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx deleted file mode 100644 index 6f5881fc0..000000000 --- a/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - 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 { useBackendContext } from "../../context/backend.js"; -import { Entity } from "../../paths/admin/create/CreatePage.js"; -import { Input } from "../form/Input.js"; -import { InputDuration } from "../form/InputDuration.js"; -import { InputGroup } from "../form/InputGroup.js"; -import { InputImage } from "../form/InputImage.js"; -import { InputLocation } from "../form/InputLocation.js"; -import { InputSelector } from "../form/InputSelector.js"; -import { InputToggle } from "../form/InputToggle.js"; -import { InputWithAddon } from "../form/InputWithAddon.js"; - -export function DefaultInstanceFormFields({ - readonlyId, - showId, -}: { - readonlyId?: boolean; - showId: boolean; -}): VNode { - const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext() - return ( - <Fragment> - {showId && ( - <InputWithAddon<Entity> - name="id" - addonBefore={`${backendURL}/instances/`} - readonly={readonlyId} - label={i18n.str`Identifier`} - tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`} - /> - )} - - <Input<Entity> - name="name" - label={i18n.str`Business name`} - tooltip={i18n.str`Legal name of the business represented by this instance.`} - /> - - <InputSelector<Entity> - name="user_type" - label={i18n.str`Type`} - tooltip={i18n.str`Different type of account can have different rules and requirements.`} - values={["business", "individual"]} - /> - - <Input<Entity> - name="email" - label={i18n.str`Email`} - tooltip={i18n.str`Contact email`} - /> - - <Input<Entity> - name="website" - label={i18n.str`Website URL`} - tooltip={i18n.str`URL.`} - /> - - <InputImage<Entity> - name="logo" - label={i18n.str`Logo`} - tooltip={i18n.str`Logo image.`} - /> - - <InputToggle<Entity> - name="use_stefan" - label={i18n.str`Pay transaction fee`} - tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`} - /> - - <InputGroup - name="address" - label={i18n.str`Address`} - tooltip={i18n.str`Physical location of the merchant.`} - > - <InputLocation name="address" /> - </InputGroup> - - <InputGroup - name="jurisdiction" - label={i18n.str`Jurisdiction`} - tooltip={i18n.str`Jurisdiction for legal disputes with the merchant.`} - > - <InputLocation name="jurisdiction" /> - </InputGroup> - - <InputDuration<Entity> - name="default_pay_delay" - label={i18n.str`Default payment delay`} - withForever - tooltip={i18n.str`Time customers have to pay an order before the offer expires by default.`} - /> - - <InputDuration<Entity> - name="default_wire_transfer_delay" - label={i18n.str`Default wire transfer delay`} - tooltip={i18n.str`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`} - withForever - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx b/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx index 41fe1374a..a6cd8014d 100644 --- a/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx +++ b/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx b/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx index 9f1b33893..d81410bdf 100644 --- a/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx +++ b/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx b/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx index cfc00148e..0b662d8de 100644 --- a/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx +++ b/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -15,57 +15,20 @@ */ /** - * - * @author Sebastian Javier Marchano (sebasjm) + * @author Nic Eigel */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { useBackendContext } from "../../context/backend.js"; import { useConfigContext } from "../../context/config.js"; -import { useInstanceKYCDetails } from "../../hooks/instance.js"; -import { LangSelector } from "./LangSelector.js"; -const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; -const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; - -interface Props { - onLogout: () => void; - onShowSettings: () => void; - mobile?: boolean; - instance: string; - admin?: boolean; - mimic?: boolean; - isPasswordOk: boolean; -} - -export function Sidebar({ - mobile, - instance, - onShowSettings, - onLogout, - admin, - mimic, - isPasswordOk -}: Props): VNode { - const config = useConfigContext(); - const { url: backendURL } = useBackendContext() +export function Sidebar(props: any): VNode { + const configData = useConfigContext(); const { i18n } = useTranslationContext(); - const kycStatus = useInstanceKYCDetails(); - const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; + console.log(configData); return ( <aside class="aside is-placed-left is-expanded" style={{ overflowY: "scroll" }}> - {mobile && ( - <div - class="footer" - onClick={(e) => { - return e.stopImmediatePropagation(); - }} - > - <LangSelector /> - </div> - )} <div class="aside-tools"> <div class="aside-tools-label"> <div> @@ -75,210 +38,66 @@ export function Sidebar({ class="is-size-7 has-text-right" style={{ lineHeight: 0, marginTop: -10 }} > - {VERSION} ({config.version}) + (Version {configData.version}) </div> </div> </div> <div class="menu is-menu-main"> - {instance ? ( - <Fragment> - <ul class="menu-list"> - <li> - <a href={"/orders"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-cash-register" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Orders</i18n.Translate> - </span> - </a> - </li> - <li> - <a href={"/inventory"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-shopping" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Inventory</i18n.Translate> - </span> - </a> - </li> - <li> - <a href={"/transfers"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-arrow-left-right" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Transfers</i18n.Translate> - </span> - </a> - </li> - <li> - <a href={"/templates"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-newspaper" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Templates</i18n.Translate> - </span> - </a> - </li> - {needKYC && ( - <li> - <a href={"/kyc"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-account-check" /> - </span> - <span class="menu-item-label">KYC Status</span> - </a> - </li> - )} - </ul> - <p class="menu-label"> - <i18n.Translate>Configuration</i18n.Translate> - </p> - <ul class="menu-list"> - <li> - <a href={"/bank"} class="has-icon"> + <Fragment> + <ul class="menu-list"> + <li> + <a href={"/key-figures"} class="has-icon"> <span class="icon"> <i class="mdi mdi-bank" /> </span> - <span class="menu-item-label"> - <i18n.Translate>Bank account</i18n.Translate> + <span class="menu-item-label"> + <i18n.Translate>Key figures</i18n.Translate> </span> - </a> - </li> - <li> - <a href={"/otp-devices"} class="has-icon"> + </a> + </li> + <li> + <a href={"/critical-errors"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-lock" /> + <i class="mdi mdi-alert" /> </span> - <span class="menu-item-label"> - <i18n.Translate>OTP Devices</i18n.Translate> + <span class="menu-item-label"> + <i18n.Translate>Critical errors</i18n.Translate> </span> - </a> - </li> - <li> - <a href={"/reserves"} class="has-icon"> + </a> + </li> + <li> + <a href={"/operating-status"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-cash" /> + <i class="mdi mdi-close-network" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Operating status</i18n.Translate> </span> - <span class="menu-item-label">Reserves</span> - </a> - </li> - <li> - <a href={"/webhooks"} class="has-icon"> + </a> + </li> + <li> + <a href={"/detail-view"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-format-wrap-tight" /> </span> - <span class="menu-item-label"> - <i18n.Translate>Webhooks</i18n.Translate> + <span class="menu-item-label"> + <i18n.Translate>Inconsistencies</i18n.Translate> </span> - </a> - </li> - <li> - <a href={"/settings"} class="has-icon"> + </a> + </li> + <li> + <a href={"/settings"} class="has-icon"> <span class="icon"> <i class="mdi mdi-square-edit-outline" /> </span> - <span class="menu-item-label"> + <span class="menu-item-label"> <i18n.Translate>Settings</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> - </Fragment> - ) : undefined} - <p class="menu-label"> - <i18n.Translate>Connection</i18n.Translate> - </p> - <ul class="menu-list"> - <li> - <a class="has-icon is-state-info is-hoverable" - onClick={(): void => onShowSettings()} - > - <span class="icon"> - <i class="mdi mdi-newspaper" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Interface</i18n.Translate> - </span> - </a> - </li> - <li> - <div> - <span style={{ width: "3rem" }} class="icon"> - <i class="mdi mdi-web" /> - </span> - <span class="menu-item-label"> - {new URL(backendURL).hostname} - </span> - </div> - </li> - <li> - <div> - <span style={{ width: "3rem" }} class="icon"> - ID - </span> - <span class="menu-item-label"> - {!instance ? "default" : instance} - </span> - </div> - </li> - {admin && !mimic && ( - <Fragment> - <p class="menu-label"> - <i18n.Translate>Instances</i18n.Translate> - </p> - <li> - <a href={"/instance/new"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-plus" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>New</i18n.Translate> - </span> - </a> - </li> - <li> - <a href={"/instances"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-format-list-bulleted" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>List</i18n.Translate> - </span> - </a> - </li> - </Fragment> - )} - {isPasswordOk ? - <li> - <a - class="has-icon is-state-info is-hoverable" - onClick={(): void => onLogout()} - > - <span class="icon"> - <i class="mdi mdi-logout default" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Log out</i18n.Translate> - </span> </a> - </li> : undefined - } - </ul> + </li> + </ul> + </Fragment> </div> </aside> ); -} +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/components/menu/index.tsx b/packages/auditor-backoffice-ui/src/components/menu/index.tsx index 015d3bd05..e411939c7 100644 --- a/packages/auditor-backoffice-ui/src/components/menu/index.tsx +++ b/packages/auditor-backoffice-ui/src/components/menu/index.tsx @@ -1,237 +1,242 @@ /* - 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. +This file is part of GNU Taler +(C) 2021-2024 Taler Systems S.A. - 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. +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. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ +GNU Taler 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. -import { ComponentChildren, Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { AdminPaths } from "../../AdminRoutes.js"; -import { InstancePaths } from "../../InstanceRoutes.js"; -import { Notification } from "../../utils/types.js"; -import { NavigationBar } from "./NavigationBar.js"; -import { Sidebar } from "./SideBar.js"; - -function getInstanceTitle(path: string, id: string): string { - switch (path) { - case InstancePaths.settings: - return `${id}: Settings`; - case InstancePaths.inventory_list: - return `${id}: Inventory`; - case InstancePaths.deposit_confirmation_list: - return `${id}: Deposit Confirmation`; - case InstancePaths.inventory_new: - return `${id}: New product`; - case InstancePaths.inventory_update: - return `${id}: Update product`; - case InstancePaths.interface: - return `${id}: Interface`; - default: - return ""; - } -} +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/> + +*/ -function getAdminTitle(path: string, instance: string) { - if (path === AdminPaths.new_instance) return `New instance`; - if (path === AdminPaths.list_instances) return `Instances`; - return getInstanceTitle(path, instance); +/** + * + * @author Sebastian Javier Marchano (sebasjm) + * @author Nic Eigel + */ + +import {ComponentChildren, Fragment, h, VNode} from "preact"; +import {useEffect, useState} from "preact/hooks"; +import {Paths} from "../../InstanceRoutes.js"; +import {Notification} from "../../utils/types.js"; +import {NavigationBar} from "./NavigationBar.js"; +import {Sidebar} from "./SideBar.js"; + +function getInstanceTitle(path: string): string { + switch (path) { + case Paths.key_figures: + return 'Key figures'; + case Paths.critical_errors: + return 'Critical errors'; + case Paths.operating_status: + return 'Operating status'; + case Paths.detail_view: + return 'Inconsistencies'; + case Paths.amount_arithmethic_inconsistency_list: + return `Amount arithmetic inconsistencies`; + case Paths.bad_sig_losses_list: + return `Bad sig losses`; + case Paths.balance_list: + return `Balances`; + case Paths.closure_lag_list: + return `Closure Lags`; + case Paths.coin_inconsistency_list: + return `Coin inconsistencies`; + case Paths.denomination_key_validity_withdraw_inconsistency_list: + return `Denomination key validity withdraw inconsistency`; + case Paths.denomination_pending_list: + return `Denominations pending`; + case Paths.denomination_without_sig_list: + return `Denominations without sigs`; + case Paths.deposit_confirmation_list: + return `Deposit confirmations`; + case Paths.deposit_confirmation_update: + return `Update deposit confirmation`; + case Paths.emergency_list: + return `Emergencies`; + case Paths.emergency_by_count_list: + return `Emergencies by count`; + case Paths.exchange_signkey_list: + return `Exchange signkeys`; + case Paths.fee_time_inconsistency_list: + return `Fee time inconsistencies`; + case Paths.historic_denomination_revenue_list: + return `Historic denomination revenue`; + case Paths.misattribution_in_inconsistency_list: + return `Misattribution in inconsistencies`; + case Paths.progress_list: + return `Progress`; + case Paths.purse_not_closed_inconsistency_list: + return `Purse not closed inconsistencies`; + case Paths.purse_list: + return `Purses`; + case Paths.refresh_hanging_list: + return `Refreshes hanging`; + case Paths.reserve_balance_insufficient_inconsistency_list: + return `Reserve balance insufficient inconsistencies`; + case Paths.reserve_balance_summary_wrong_inconsistency_list: + return `Reserve balance summary wrong inconsistencies`; + case Paths.reserve_in_inconsistency_list: + return `Reserves in inconsistencies`; + case Paths.reserve_not_closed_inconsistency_list: + return `Reserves not closed inconsistencies`; + case Paths.row_inconsistency_list: + return `Row inconsistencies`; + case Paths.row_minor_inconsistency_list: + return `Row minor inconsistencies`; + case Paths.wire_format_inconsistency_list: + return `Wire format inconsistencies`; + case Paths.wire_out_inconsistency_list: + return `Wire out inconsistencies`; + case Paths.settings: + return `Settings`; + default: + return ""; + } } interface MenuProps { - title?: string; - path: string; - instance: string; - admin?: boolean; - onLogout?: () => void; - onShowSettings: () => void; - setInstanceName: (s: string) => void; - isPasswordOk: boolean; + title?: string; + path: string; + onShowSettings: () => void; } function WithTitle({ - title, - children, -}: { - title: string; - children: ComponentChildren; + title, + children, + }: { + title: string; + children: ComponentChildren; }): VNode { - useEffect(() => { - document.title = `Taler Backoffice: ${title}`; - }, [title]); - return <Fragment>{children}</Fragment>; + useEffect(() => { + document.title = `Taler Backoffice: ${title}`; + }, [title]); + return <Fragment>{children}</Fragment>; } export function Menu({ - onLogout, - onShowSettings, - title, - instance, - path, - admin, - setInstanceName, - isPasswordOk -}: MenuProps): VNode { - const [mobileOpen, setMobileOpen] = useState(false); - - const titleWithSubtitle = title - ? title - : !admin - ? getInstanceTitle(path, instance) - : getAdminTitle(path, instance); - const adminInstance = instance === "default"; - const mimic = admin && !adminInstance; - return ( - <WithTitle title={titleWithSubtitle}> - <div - class={mobileOpen ? "has-aside-mobile-expanded" : ""} - onClick={() => setMobileOpen(false)} - > - <NavigationBar - onMobileMenu={() => setMobileOpen(!mobileOpen)} - title={titleWithSubtitle} - /> - - {onLogout && ( - <Sidebar - onShowSettings={onShowSettings} - onLogout={onLogout} - admin={admin} - mimic={mimic} - instance={instance} - mobile={mobileOpen} - isPasswordOk={isPasswordOk} - /> - )} - - {mimic && ( - <nav class="level" style={{ - zIndex: 100, - position: "fixed", - width: "50%", - marginLeft: "20%" - }}> - <div class="level-item has-text-centered has-background-warning"> - <p class="is-size-5"> - You are viewing the instance <b>"{instance}"</b>.{" "} - <a - href="#/instances" - onClick={(e) => { - setInstanceName("default"); - }} - > - go back - </a> - </p> + onShowSettings, + title, + path, + }: MenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); + const titleWithSubtitle = getInstanceTitle(path.replace("app/#", "")); + return ( + <WithTitle title={titleWithSubtitle}> + <div + class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={titleWithSubtitle} + /> + + <Sidebar + onShowSettings={onShowSettings} + mobile={mobileOpen} + /> </div> - </nav> - )} - </div> - </WithTitle> - ); + </WithTitle> + ); } interface NotYetReadyAppMenuProps { - title: string; - onShowSettings: () => void; - onLogout?: () => void; - isPasswordOk: boolean; + title: string; + onShowSettings: () => void; } interface NotifProps { - notification?: Notification; + notification?: Notification; } -export function NotificationCard({ - notification: n, -}: NotifProps): VNode | null { - if (!n) return null; - return ( - <div class="notification"> - <div class="columns is-vcentered"> - <div class="column is-12"> - <article - class={ - n.type === "ERROR" - ? "message is-danger" - : n.type === "WARN" - ? "message is-warning" - : "message is-info" - } - > - <div class="message-header"> - <p>{n.message}</p> + +export function NotificationCard({notification: n}: NotifProps): VNode | null { + if (!n) return null; + return ( + <div class="notification"> + <div class="columns is-vcentered"> + <div class="column is-12"> + <article + class={ + n.type === "ERROR" + ? "message is-danger" + : n.type === "WARN" + ? "message is-warning" + : "message is-info" + } + > + <div class="message-header"> + <p>{n.message}</p> + </div> + {n.description && ( + <div class="message-body"> + <div>{n.description}</div> + {n.details && <pre>{n.details}</pre>} + </div> + )} + </article> + </div> </div> - {n.description && ( - <div class="message-body"> - <div>{n.description}</div> - {n.details && <pre>{n.details}</pre>} - </div> - )} - </article> </div> - </div> - </div> - ); + ); } interface NotConnectedAppMenuProps { - title: string; + 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> - ); + 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({ - onLogout, - onShowSettings, - title, - isPasswordOk -}: NotYetReadyAppMenuProps): 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} - /> - {onLogout && ( - <Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} isPasswordOk={isPasswordOk} /> - )} - </div> - ); -} + onShowSettings, + title + }: NotYetReadyAppMenuProps): 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} + /> + ( + <Sidebar onShowSettings={onShowSettings} instance="" mobile={mobileOpen}/> + ) + </div> + ); +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/components/modal/index.tsx b/packages/auditor-backoffice-ui/src/components/modal/index.tsx index 8372c84cc..ab2834d86 100644 --- a/packages/auditor-backoffice-ui/src/components/modal/index.tsx +++ b/packages/auditor-backoffice-ui/src/components/modal/index.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -22,11 +22,11 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { useInstanceContext } from "../../context/instance.js"; +import { useEntityContext } from "../../context/entity.js"; import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; import { Spinner } from "../exception/loading.js"; -import { FormProvider } from "../form/FormProvider.js"; -import { Input } from "../form/Input.js"; +import { FormProvider } from "../forms/FormProvider.js"; +import { Input } from "../forms/Input.js"; interface Props { active?: boolean; @@ -310,9 +310,9 @@ export function UpdateTokenModal({ (k) => (errors as any)[k] !== undefined, ); - const instance = useInstanceContext(); + const instance = useEntityContext(); - const text = i18n.str`You are updating the access token from instance with id ${instance.id}`; + const text = i18n.str`You are updating the access token from instance with id `; return ( <ClearConfirmModal diff --git a/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx deleted file mode 100644 index 073382fb1..000000000 --- a/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - 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 { ComponentChildren, h, VNode } from "preact"; - -interface Props { - onCreateAnother?: () => void; - onConfirm: () => void; - children: ComponentChildren; -} - -export function CreatedSuccessfully({ - children, - onConfirm, - onCreateAnother, -}: Props): VNode { - return ( - <div class="columns is-fullwidth is-vcentered mt-3"> - <div class="column" /> - <div class="column is-four-fifths"> - <div class="card"> - <header class="card-header has-background-success"> - <p class="card-header-title has-text-white-ter">Success.</p> - </header> - <div class="card-content">{children}</div> - </div> - <div class="buttons is-right"> - {onCreateAnother && ( - <button class="button is-info" onClick={onCreateAnother}> - Create another - </button> - )} - <button class="button is-info" onClick={onConfirm}> - Continue - </button> - </div> - </div> - <div class="column" /> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx b/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx deleted file mode 100644 index af594de0f..000000000 --- a/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - 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 } from "preact"; -import { Notifications } from "./index.js"; - -export default { - title: "Components/Notification", - component: Notifications, - argTypes: { - removeNotification: { action: "removeNotification" }, - }, -}; - -export const Info = (a: any) => <Notifications {...a} />; -Info.args = { - notifications: [ - { - message: "Title", - description: "Some large description", - type: "INFO", - }, - ], -}; -export const Warn = (a: any) => <Notifications {...a} />; -Warn.args = { - notifications: [ - { - message: "Title", - description: "Some large description", - type: "WARN", - }, - ], -}; -export const Error = (a: any) => <Notifications {...a} />; -Error.args = { - notifications: [ - { - message: "Title", - description: "Some large description", - type: "ERROR", - }, - ], -}; diff --git a/packages/auditor-backoffice-ui/src/components/notifications/index.tsx b/packages/auditor-backoffice-ui/src/components/notifications/index.tsx deleted file mode 100644 index 235c75577..000000000 --- a/packages/auditor-backoffice-ui/src/components/notifications/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - 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 { MessageType, Notification } from "../../utils/types.js"; - -interface Props { - notifications: Notification[]; - removeNotification?: (n: Notification) => void; -} - -function messageStyle(type: MessageType): string { - switch (type) { - case "INFO": - return "message is-info"; - case "WARN": - return "message is-warning"; - case "ERROR": - return "message is-danger"; - case "SUCCESS": - return "message is-success"; - default: - return "message"; - } -} - -export function Notifications({ - notifications, - removeNotification, -}: Props): VNode { - return ( - <div class="toast"> - {notifications.map((n, i) => ( - <article key={i} class={messageStyle(n.type)}> - <div class="message-header"> - <p>{n.message}</p> - <button - class="delete" - onClick={() => removeNotification && removeNotification(n)} - /> - </div> - {n.description && <div class="message-body">{n.description}</div>} - </article> - ))} - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx b/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx deleted file mode 100644 index 0bc629d46..000000000 --- a/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx +++ /dev/null @@ -1,349 +0,0 @@ -/* - 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, Component } from "preact"; - -interface Props { - closeFunction?: () => void; - dateReceiver?: (d: Date) => void; - opened?: boolean; -} -interface State { - displayedMonth: number; - displayedYear: number; - selectYearMode: boolean; - currentDate: Date; -} - -// inspired by https://codepen.io/m4r1vs/pen/MOOxyE -export class DatePicker extends Component<Props, State> { - closeDatePicker() { - this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent - } - - /** - * Gets fired when a day gets clicked. - * @param {object} e The event thrown by the <span /> element clicked - */ - dayClicked(e: any) { - const element = e.target; // the actual element clicked - - if (element.innerHTML === "") return false; // don't continue if <span /> empty - - // get date from clicked element (gets attached when rendered) - const date = new Date(element.getAttribute("data-value")); - - // update the state - this.setState({ currentDate: date }); - this.passDateToParent(date); - } - - /** - * returns days in month as array - * @param {number} month the month to display - * @param {number} year the year to display - */ - getDaysByMonth(month: number, year: number) { - const calendar = []; - - const date = new Date(year, month, 1); // month to display - - const firstDay = new Date(year, month, 1).getDay(); // first weekday of month - const lastDate = new Date(year, month + 1, 0).getDate(); // last date of month - - let day: number | null = 0; - - // the calendar is 7*6 fields big, so 42 loops - for (let i = 0; i < 42; i++) { - if (i >= firstDay && day !== null) day = day + 1; - if (day !== null && day > lastDate) day = null; - - // append the calendar Array - calendar.push({ - day: day === 0 || day === null ? null : day, // null or number - date: day === 0 || day === null ? null : new Date(year, month, day), // null or Date() - today: - day === now.getDate() && - month === now.getMonth() && - year === now.getFullYear(), // boolean - }); - } - - return calendar; - } - - /** - * Display previous month by updating state - */ - displayPrevMonth() { - if (this.state.displayedMonth <= 0) { - this.setState({ - displayedMonth: 11, - displayedYear: this.state.displayedYear - 1, - }); - } else { - this.setState({ - displayedMonth: this.state.displayedMonth - 1, - }); - } - } - - /** - * Display next month by updating state - */ - displayNextMonth() { - if (this.state.displayedMonth >= 11) { - this.setState({ - displayedMonth: 0, - displayedYear: this.state.displayedYear + 1, - }); - } else { - this.setState({ - displayedMonth: this.state.displayedMonth + 1, - }); - } - } - - /** - * Display the selected month (gets fired when clicking on the date string) - */ - displaySelectedMonth() { - if (this.state.selectYearMode) { - this.toggleYearSelector(); - } else { - if (!this.state.currentDate) return false; - this.setState({ - displayedMonth: this.state.currentDate.getMonth(), - displayedYear: this.state.currentDate.getFullYear(), - }); - } - } - - toggleYearSelector() { - this.setState({ selectYearMode: !this.state.selectYearMode }); - } - - changeDisplayedYear(e: any) { - const element = e.target; - this.toggleYearSelector(); - this.setState({ - displayedYear: parseInt(element.innerHTML, 10), - displayedMonth: 0, - }); - } - - /** - * Pass the selected date to parent when 'OK' is clicked - */ - passSavedDateDateToParent() { - this.passDateToParent(this.state.currentDate); - } - passDateToParent(date: Date) { - if (typeof this.props.dateReceiver === "function") - this.props.dateReceiver(date); - this.closeDatePicker(); - } - - componentDidUpdate() { - if (this.state.selectYearMode) { - document.getElementsByClassName("selected")[0].scrollIntoView(); // works in every browser incl. IE, replace with scrollIntoViewIfNeeded when browsers support it - } - } - - constructor() { - super(); - - this.closeDatePicker = this.closeDatePicker.bind(this); - this.dayClicked = this.dayClicked.bind(this); - this.displayNextMonth = this.displayNextMonth.bind(this); - this.displayPrevMonth = this.displayPrevMonth.bind(this); - this.getDaysByMonth = this.getDaysByMonth.bind(this); - this.changeDisplayedYear = this.changeDisplayedYear.bind(this); - this.passDateToParent = this.passDateToParent.bind(this); - this.toggleYearSelector = this.toggleYearSelector.bind(this); - this.displaySelectedMonth = this.displaySelectedMonth.bind(this); - - this.state = { - currentDate: now, - displayedMonth: now.getMonth(), - displayedYear: now.getFullYear(), - selectYearMode: false, - }; - } - - render() { - const { currentDate, displayedMonth, displayedYear, selectYearMode } = - this.state; - - return ( - <div> - <div class={`datePicker ${this.props.opened && "datePicker--opened"}`}> - <div class="datePicker--titles"> - <h3 - style={{ - color: selectYearMode - ? "rgba(255,255,255,.87)" - : "rgba(255,255,255,.57)", - }} - onClick={this.toggleYearSelector} - > - {currentDate.getFullYear()} - </h3> - <h2 - style={{ - color: !selectYearMode - ? "rgba(255,255,255,.87)" - : "rgba(255,255,255,.57)", - }} - onClick={this.displaySelectedMonth} - > - {dayArr[currentDate.getDay()]},{" "} - {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()} - </h2> - </div> - - {!selectYearMode && ( - <nav> - <span onClick={this.displayPrevMonth} class="icon"> - <i - style={{ transform: "rotate(180deg)" }} - class="mdi mdi-forward" - /> - </span> - <h4> - {monthArrShortFull[displayedMonth]} {displayedYear} - </h4> - <span onClick={this.displayNextMonth} class="icon"> - <i class="mdi mdi-forward" /> - </span> - </nav> - )} - - <div class="datePicker--scroll"> - {!selectYearMode && ( - <div class="datePicker--calendar"> - <div class="datePicker--dayNames"> - {["S", "M", "T", "W", "T", "F", "S"].map((day, i) => ( - <span key={i}>{day}</span> - ))} - </div> - - <div onClick={this.dayClicked} class="datePicker--days"> - {/* - Loop through the calendar object returned by getDaysByMonth(). - */} - - {this.getDaysByMonth( - this.state.displayedMonth, - this.state.displayedYear, - ).map((day) => { - let selected = false; - - if (currentDate && day.date) - selected = - currentDate.toLocaleDateString() === - day.date.toLocaleDateString(); - - return ( - <span - key={day.day} - class={ - (day.today ? "datePicker--today " : "") + - (selected ? "datePicker--selected" : "") - } - disabled={!day.date} - data-value={day.date} - > - {day.day} - </span> - ); - })} - </div> - </div> - )} - - {selectYearMode && ( - <div class="datePicker--selectYear"> - {yearArr.map((year) => ( - <span - key={year} - class={year === displayedYear ? "selected" : ""} - onClick={this.changeDisplayedYear} - > - {year} - </span> - ))} - </div> - )} - </div> - </div> - - <div - class="datePicker--background" - onClick={this.closeDatePicker} - style={{ - display: this.props.opened ? "block" : "none", - }} - /> - </div> - ); - } -} - -const monthArrShortFull = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - -const monthArrShort = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -]; - -const dayArr = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - -const now = new Date(); - -const yearArr: number[] = []; - -for (let i = 2010; i <= now.getFullYear() + 10; i++) { - yearArr.push(i); -} diff --git a/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx deleted file mode 100644 index 8f74d55ac..000000000 --- a/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - 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, FunctionalComponent } from "preact"; -import { useState } from "preact/hooks"; -import { DurationPicker as TestedComponent } from "./DurationPicker.js"; - -export default { - title: "Components/Picker/Duration", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - goBack: { action: "goBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, { - days: true, - minutes: true, - hours: true, - seconds: true, - value: 10000000, -}); - -export const WithState = () => { - const [v, s] = useState<number>(1000000); - return <TestedComponent value={v} onChange={s} days minutes hours seconds />; -}; diff --git a/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx deleted file mode 100644 index ba003cce5..000000000 --- a/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* - 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 "../../scss/DurationPicker.scss"; - -export interface Props { - hours?: boolean; - minutes?: boolean; - seconds?: boolean; - days?: boolean; - onChange: (value: number) => void; - value: number; -} - -// inspiration taken from https://github.com/flurmbo/react-duration-picker -export function DurationPicker({ - days, - hours, - minutes, - seconds, - onChange, - value, -}: Props): VNode { - const ss = 1000; - const ms = ss * 60; - const hs = ms * 60; - const ds = hs * 24; - const { i18n } = useTranslationContext(); - - return ( - <div class="rdp-picker"> - {days && ( - <DurationColumn - unit={i18n.str`days`} - max={99} - value={Math.floor(value / ds)} - onDecrease={value >= ds ? () => onChange(value - ds) : undefined} - onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined} - onChange={(diff) => onChange(value + diff * ds)} - /> - )} - {hours && ( - <DurationColumn - unit={i18n.str`hours`} - max={23} - min={1} - value={Math.floor(value / hs) % 24} - onDecrease={value >= hs ? () => onChange(value - hs) : undefined} - onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined} - onChange={(diff) => onChange(value + diff * hs)} - /> - )} - {minutes && ( - <DurationColumn - unit={i18n.str`minutes`} - max={59} - min={1} - value={Math.floor(value / ms) % 60} - onDecrease={value >= ms ? () => onChange(value - ms) : undefined} - onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined} - onChange={(diff) => onChange(value + diff * ms)} - /> - )} - {seconds && ( - <DurationColumn - unit={i18n.str`seconds`} - max={59} - value={Math.floor(value / ss) % 60} - onDecrease={value >= ss ? () => onChange(value - ss) : undefined} - onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined} - onChange={(diff) => onChange(value + diff * ss)} - /> - )} - </div> - ); -} - -interface ColProps { - unit: string; - min?: number; - max: number; - value: number; - onIncrease?: () => void; - onDecrease?: () => void; - onChange?: (diff: number) => void; -} - -function InputNumber({ - initial, - onChange, -}: { - initial: number; - onChange: (n: number) => void; -}) { - const [value, handler] = useState<{ v: string }>({ - v: toTwoDigitString(initial), - }); - - return ( - <input - value={value.v} - onBlur={(e) => onChange(parseInt(value.v, 10))} - onInput={(e) => { - e.preventDefault(); - const n = Number.parseInt(e.currentTarget.value, 10); - if (isNaN(n)) return handler({ v: toTwoDigitString(initial) }); - return handler({ v: toTwoDigitString(n) }); - }} - style={{ - width: 50, - border: "none", - fontSize: "inherit", - background: "inherit", - }} - /> - ); -} - -function DurationColumn({ - unit, - min = 0, - max, - value, - onIncrease, - onDecrease, - onChange, -}: ColProps): VNode { - const cellHeight = 35; - return ( - <div class="rdp-column-container"> - <div class="rdp-masked-div"> - <hr class="rdp-reticule" style={{ top: cellHeight * 2 - 1 }} /> - <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} /> - - <div class="rdp-column" style={{ top: 0 }}> - <div class="rdp-cell" key={value - 2}> - {onDecrease && ( - <button - style={{ width: "100%", textAlign: "center", margin: 5 }} - onClick={onDecrease} - > - <span class="icon"> - <i class="mdi mdi-chevron-up" /> - </span> - </button> - )} - </div> - <div class="rdp-cell" key={value - 1}> - {value > min ? toTwoDigitString(value - 1) : ""} - </div> - <div class="rdp-cell rdp-center" key={value}> - {onChange ? ( - <InputNumber - initial={value} - onChange={(n) => onChange(n - value)} - /> - ) : ( - toTwoDigitString(value) - )} - <div>{unit}</div> - </div> - - <div class="rdp-cell" key={value + 1}> - {value < max ? toTwoDigitString(value + 1) : ""} - </div> - - <div class="rdp-cell" key={value + 2}> - {onIncrease && ( - <button - style={{ width: "100%", textAlign: "center", margin: 5 }} - onClick={onIncrease} - > - <span class="icon"> - <i class="mdi mdi-chevron-down" /> - </span> - </button> - )} - </div> - </div> - </div> - </div> - ); -} - -function toTwoDigitString(n: number) { - if (n < 10) { - return `0${n}`; - } - return `${n}`; -} diff --git a/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx deleted file mode 100644 index 2d5a54cde..000000000 --- a/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - 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 { InventoryProductForm as TestedComponent } from "./InventoryProductForm.js"; - -export default { - title: "Components/Product/Add", - component: TestedComponent, - argTypes: { - onAddProduct: { action: "onAddProduct" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const WithASimpleList = createExample(TestedComponent, { - inventory: [ - { - id: "this id", - description: "this is the description", - } as any, - ], -}); - -export const WithAProductSelected = createExample(TestedComponent, { - inventory: [], - currentProducts: { - thisid: { - quantity: 1, - product: { - id: "asd", - description: "asdsadsad", - } as any, - }, - }, -}); diff --git a/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx deleted file mode 100644 index 377d9c1ba..000000000 --- a/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/* - 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { MerchantBackend, WithId } from "../../declaration.js"; -import { ProductMap } from "../../paths/instance/orders/create/CreatePage.js"; -import { FormErrors, FormProvider } from "../form/FormProvider.js"; -import { InputNumber } from "../form/InputNumber.js"; -import { InputSearchOnList } from "../form/InputSearchOnList.js"; - -type Form = { - product: MerchantBackend.Products.ProductDetail & WithId; - quantity: number; -}; - -interface Props { - currentProducts: ProductMap; - onAddProduct: ( - product: MerchantBackend.Products.ProductDetail & WithId, - quantity: number, - ) => void; - inventory: (MerchantBackend.Products.ProductDetail & WithId)[]; -} - -export function InventoryProductForm({ - currentProducts, - onAddProduct, - inventory, -}: Props): VNode { - const initialState = { quantity: 1 }; - const [state, setState] = useState<Partial<Form>>(initialState); - const [errors, setErrors] = useState<FormErrors<Form>>({}); - - const { i18n } = useTranslationContext(); - - const productWithInfiniteStock = - state.product && state.product.total_stock === -1; - - const submit = (): void => { - if (!state.product) { - setErrors({ - product: i18n.str`You must enter a valid product identifier.`, - }); - return; - } - if (productWithInfiniteStock) { - onAddProduct(state.product, 1); - } else { - if (!state.quantity || state.quantity <= 0) { - setErrors({ quantity: i18n.str`Quantity must be greater than 0!` }); - return; - } - const currentStock = - state.product.total_stock - - state.product.total_lost - - state.product.total_sold; - const p = currentProducts[state.product.id]; - if (p) { - if (state.quantity + p.quantity > currentStock) { - const left = currentStock - p.quantity; - setErrors({ - quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`, - }); - return; - } - onAddProduct(state.product, state.quantity + p.quantity); - } else { - if (state.quantity > currentStock) { - const left = currentStock; - setErrors({ - quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`, - }); - return; - } - onAddProduct(state.product, state.quantity); - } - } - - setState(initialState); - }; - - return ( - <FormProvider<Form> errors={errors} object={state} valueHandler={setState}> - <InputSearchOnList - label={i18n.str`Search product`} - selected={state.product} - onChange={(p) => setState((v) => ({ ...v, product: p }))} - list={inventory} - withImage - /> - {state.product && ( - <div class="columns mt-5"> - <div class="column is-two-thirds"> - {!productWithInfiniteStock && ( - <InputNumber<Form> - name="quantity" - label={i18n.str`Quantity`} - tooltip={i18n.str`how many products will be added`} - /> - )} - </div> - <div class="column"> - <div class="buttons is-right"> - <button class="button is-success" onClick={submit}> - <i18n.Translate>Add from inventory</i18n.Translate> - </button> - </div> - </div> - </div> - )} - </FormProvider> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx deleted file mode 100644 index c6d280f94..000000000 --- a/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx +++ /dev/null @@ -1,215 +0,0 @@ -/* - 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { useCallback, useEffect, useState } from "preact/hooks"; -import * as yup from "yup"; -import { MerchantBackend } from "../../declaration.js"; -import { useListener } from "../../hooks/listener.js"; -import { NonInventoryProductSchema as schema } from "../../schemas/index.js"; -import { FormErrors, FormProvider } from "../form/FormProvider.js"; -import { Input } from "../form/Input.js"; -import { InputCurrency } from "../form/InputCurrency.js"; -import { InputImage } from "../form/InputImage.js"; -import { InputNumber } from "../form/InputNumber.js"; -import { InputTaxes } from "../form/InputTaxes.js"; - -type Entity = MerchantBackend.Product; - -interface Props { - onAddProduct: (p: Entity) => Promise<void>; - productToEdit?: Entity; -} -export function NonInventoryProductFrom({ - productToEdit, - onAddProduct, -}: Props): VNode { - const [showCreateProduct, setShowCreateProduct] = useState(false); - - const isEditing = !!productToEdit; - - useEffect(() => { - setShowCreateProduct(isEditing); - }, [isEditing]); - - const [submitForm, addFormSubmitter] = useListener< - Partial<MerchantBackend.Product> | undefined - >((result) => { - if (result) { - setShowCreateProduct(false); - return onAddProduct({ - quantity: result.quantity || 0, - taxes: result.taxes || [], - description: result.description || "", - image: result.image || "", - price: result.price || "", - unit: result.unit || "", - }); - } - return Promise.resolve(); - }); - - const { i18n } = useTranslationContext(); - - return ( - <Fragment> - <div class="buttons"> - <button - class="button is-success" - data-tooltip={i18n.str`describe and add a product that is not in the inventory list`} - onClick={() => setShowCreateProduct(true)} - > - <i18n.Translate>Add custom product</i18n.Translate> - </button> - </div> - {showCreateProduct && ( - <div class="modal is-active"> - <div - class="modal-background " - onClick={() => setShowCreateProduct(false)} - /> - <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">{i18n.str`Complete information of the product`}</p> - <button - class="delete " - aria-label="close" - onClick={() => setShowCreateProduct(false)} - /> - </header> - <section class="modal-card-body"> - <ProductForm - initial={productToEdit} - onSubscribe={addFormSubmitter} - /> - </section> - <footer class="modal-card-foot"> - <div class="buttons is-right" style={{ width: "100%" }}> - <button - class="button " - onClick={() => setShowCreateProduct(false)} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - <button - class="button is-info " - disabled={!submitForm} - onClick={submitForm} - > - <i18n.Translate>Confirm</i18n.Translate> - </button> - </div> - </footer> - </div> - <button - class="modal-close is-large " - aria-label="close" - onClick={() => setShowCreateProduct(false)} - /> - </div> - )} - </Fragment> - ); -} - -interface ProductProps { - onSubscribe: (c?: () => Entity | undefined) => void; - initial?: Partial<Entity>; -} - -interface NonInventoryProduct { - quantity: number; - description: string; - unit: string; - price: string; - image: string; - taxes: MerchantBackend.Tax[]; -} - -export function ProductForm({ onSubscribe, initial }: ProductProps): VNode { - const [value, valueHandler] = useState<Partial<NonInventoryProduct>>({ - taxes: [], - ...initial, - }); - let errors: FormErrors<Entity> = {}; - try { - schema.validateSync(value, { abortEarly: false }); - } catch (err) { - if (err instanceof yup.ValidationError) { - const yupErrors = err.inner as yup.ValidationError[]; - errors = yupErrors.reduce( - (prev, cur) => - !cur.path ? prev : { ...prev, [cur.path]: cur.message }, - {}, - ); - } - } - - const submit = useCallback((): Entity | undefined => { - return value as MerchantBackend.Product; - }, [value]); - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - useEffect(() => { - onSubscribe(hasErrors ? undefined : submit); - }, [submit, hasErrors]); - - const { i18n } = useTranslationContext(); - - return ( - <div> - <FormProvider<NonInventoryProduct> - name="product" - errors={errors} - object={value} - valueHandler={valueHandler} - > - <InputImage<NonInventoryProduct> - name="image" - label={i18n.str`Image`} - tooltip={i18n.str`photo of the product`} - /> - <Input<NonInventoryProduct> - name="description" - inputType="multiline" - label={i18n.str`Description`} - tooltip={i18n.str`full product description`} - /> - <Input<NonInventoryProduct> - name="unit" - label={i18n.str`Unit`} - tooltip={i18n.str`name of the product unit`} - /> - <InputCurrency<NonInventoryProduct> - name="price" - label={i18n.str`Price`} - tooltip={i18n.str`amount in the current currency`} - /> - - <InputNumber<NonInventoryProduct> - name="quantity" - label={i18n.str`Quantity`} - tooltip={i18n.str`how many products will be added`} - /> - - <InputTaxes<NonInventoryProduct> name="taxes" label={i18n.str`Taxes`} /> - </FormProvider> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx deleted file mode 100644 index e91e8c876..000000000 --- a/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/* - 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 } from "preact"; -import { useCallback, useEffect, useState } from "preact/hooks"; -import * as yup from "yup"; -import { useBackendContext } from "../../context/backend.js"; -import { MerchantBackend } from "../../declaration.js"; -import { - ProductCreateSchema as createSchema, - ProductUpdateSchema as updateSchema, -} from "../../schemas/index.js"; -import { FormErrors, FormProvider } from "../form/FormProvider.js"; -import { Input } from "../form/Input.js"; -import { InputCurrency } from "../form/InputCurrency.js"; -import { InputImage } from "../form/InputImage.js"; -import { InputNumber } from "../form/InputNumber.js"; -import { InputStock, Stock } from "../form/InputStock.js"; -import { InputTaxes } from "../form/InputTaxes.js"; -import { InputWithAddon } from "../form/InputWithAddon.js"; - -type Entity = MerchantBackend.Products.ProductDetail & { product_id: string }; - -interface Props { - onSubscribe: (c?: () => Entity | undefined) => void; - initial?: Partial<Entity>; - alreadyExist?: boolean; -} - -export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { - const [value, valueHandler] = useState<Partial<Entity & { stock: Stock }>>({ - address: {}, - description_i18n: {}, - taxes: [], - next_restock: { t_s: "never" }, - price: ":0", - ...initial, - stock: - !initial || initial.total_stock === -1 - ? undefined - : { - current: initial.total_stock || 0, - lost: initial.total_lost || 0, - sold: initial.total_sold || 0, - address: initial.address, - nextRestock: initial.next_restock, - }, - }); - let errors: FormErrors<Entity> = {}; - - try { - (alreadyExist ? updateSchema : createSchema).validateSync(value, { - abortEarly: false, - }); - } catch (err) { - if (err instanceof yup.ValidationError) { - const yupErrors = err.inner as yup.ValidationError[]; - errors = yupErrors.reduce( - (prev, cur) => - !cur.path ? prev : { ...prev, [cur.path]: cur.message }, - {}, - ); - } - } - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submit = useCallback((): Entity | undefined => { - const stock: Stock = (value as any).stock; - - if (!stock) { - value.total_stock = -1; - } else { - value.total_stock = stock.current; - value.total_lost = stock.lost; - value.next_restock = - stock.nextRestock instanceof Date - ? { t_s: stock.nextRestock.getTime() / 1000 } - : stock.nextRestock; - value.address = stock.address; - } - delete (value as any).stock; - - if (typeof value.minimum_age !== "undefined" && value.minimum_age < 1) { - delete value.minimum_age; - } - - return value as MerchantBackend.Products.ProductDetail & { - product_id: string; - }; - }, [value]); - - useEffect(() => { - onSubscribe(hasErrors ? undefined : submit); - }, [submit, hasErrors]); - - const { url: backendURL } = useBackendContext() - const { i18n } = useTranslationContext(); - - return ( - <div> - <FormProvider<Entity> - name="product" - errors={errors} - object={value} - valueHandler={valueHandler} - > - {alreadyExist ? undefined : ( - <InputWithAddon<Entity> - name="product_id" - addonBefore={`${backendURL}/product/`} - label={i18n.str`ID`} - tooltip={i18n.str`product identification to use in URLs (for internal use only)`} - /> - )} - <InputImage<Entity> - name="image" - label={i18n.str`Image`} - tooltip={i18n.str`illustration of the product for customers`} - /> - <Input<Entity> - name="description" - inputType="multiline" - label={i18n.str`Description`} - tooltip={i18n.str`product description for customers`} - /> - <InputNumber<Entity> - name="minimum_age" - label={i18n.str`Age restriction`} - tooltip={i18n.str`is this product restricted for customer below certain age?`} - help={i18n.str`minimum age of the buyer`} - /> - <Input<Entity> - name="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`} - help={i18n.str`exajmple: kg, items or liters`} - /> - <InputCurrency<Entity> - name="price" - label={i18n.str`Price per unit`} - tooltip={i18n.str`sale price for customers, including taxes, for above units of the product`} - /> - <InputStock - name="stock" - label={i18n.str`Stock`} - alreadyExist={alreadyExist} - tooltip={i18n.str`inventory for products with finite supply (for internal use only)`} - /> - <InputTaxes<Entity> - name="taxes" - label={i18n.str`Taxes`} - tooltip={i18n.str`taxes included in the product price, exposed to customers`} - /> - </FormProvider> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx b/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx deleted file mode 100644 index 25751dd96..000000000 --- a/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - 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 { Amounts } from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import emptyImage from "../../assets/empty.png"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { MerchantBackend } from "../../declaration.js"; - -interface Props { - list: MerchantBackend.Product[]; - actions?: { - name: string; - tooltip: string; - handler: (d: MerchantBackend.Product, index: number) => void; - }[]; -} -export function ProductList({ list, actions = [] }: Props): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="table-container"> - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>image</i18n.Translate> - </th> - <th> - <i18n.Translate>description</i18n.Translate> - </th> - <th> - <i18n.Translate>quantity</i18n.Translate> - </th> - <th> - <i18n.Translate>unit price</i18n.Translate> - </th> - <th> - <i18n.Translate>total price</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {list.map((entry, index) => { - const unitPrice = !entry.price ? "0" : entry.price; - const totalPrice = !entry.price - ? "0" - : Amounts.stringify( - Amounts.mult( - Amounts.parseOrThrow(entry.price), - entry.quantity, - ).amount, - ); - - return ( - <tr key={index}> - <td> - <img - style={{ height: 32, width: 32 }} - src={entry.image ? entry.image : emptyImage} - /> - </td> - <td>{entry.description}</td> - <td> - {entry.quantity === 0 - ? "--" - : `${entry.quantity} ${entry.unit}`} - </td> - <td>{unitPrice}</td> - <td>{totalPrice}</td> - <td class="is-actions-cell right-sticky"> - {actions.map((a, i) => { - return ( - <div key={i} class="buttons is-right"> - <button - class="button is-small is-danger has-tooltip-left" - data-tooltip={a.tooltip} - type="button" - onClick={() => a.handler(entry, index)} - > - {a.name} - </button> - </div> - ); - })} - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/context/backend.test.ts b/packages/auditor-backoffice-ui/src/context/backend.test.ts deleted file mode 100644 index 359859819..000000000 --- a/packages/auditor-backoffice-ui/src/context/backend.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - 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 * as tests from "@gnu-taler/web-util/testing"; -import { ComponentChildren, h, VNode } from "preact"; -import { AccessToken, MerchantBackend } from "../declaration.js"; -import { - useAdminAPI, - useInstanceAPI, - useManagementAPI, -} from "../hooks/instance.js"; -import { expect } from "chai"; -import { ApiMockEnvironment } from "../hooks/testing.js"; -import { - API_CREATE_INSTANCE, - API_NEW_LOGIN, - API_UPDATE_CURRENT_INSTANCE_AUTH, - API_UPDATE_INSTANCE_AUTH_BY_ID, -} from "../hooks/urls.js"; - -interface TestingContextProps { - children?: ComponentChildren; -} - -describe("backend context api ", () => { - it("should use new token after updating the instance token in the settings as user", async () => { - const env = new ApiMockEnvironment(); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const instance = useInstanceAPI(); - const management = useManagementAPI("default"); - const admin = useAdminAPI(); - - return { instance, management, admin }; - }, - {}, - [ - ({ instance, management, admin }) => { - env.addRequestExpectation(API_UPDATE_INSTANCE_AUTH_BY_ID("default"), { - request: { - method: "token", - token: "another_token", - }, - response: { - name: "instance_name", - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - env.addRequestExpectation(API_NEW_LOGIN, { - auth: "another_token", - request: { - scope: "write", - duration: { - "d_us": "forever", - }, - refreshable: true, - }, - - }); - - management.setNewAccessToken(undefined,"another_token" as AccessToken); - }, - ({ instance, management, admin }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - env.addRequestExpectation(API_CREATE_INSTANCE, { - // auth: "another_token", - request: { - id: "new_instance_id", - } as MerchantBackend.Instances.InstanceConfigurationMessage, - }); - - admin.createInstance({ - id: "new_instance_id", - } as MerchantBackend.Instances.InstanceConfigurationMessage); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should use new token after updating the instance token in the settings as admin", async () => { - const env = new ApiMockEnvironment(); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const instance = useInstanceAPI(); - const management = useManagementAPI("default"); - const admin = useAdminAPI(); - - return { instance, management, admin }; - }, - {}, - [ - ({ instance, management, admin }) => { - env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, { - request: { - method: "token", - token: "another_token", - }, - response: { - name: "instance_name", - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - env.addRequestExpectation(API_NEW_LOGIN, { - auth: "another_token", - request: { - scope: "write", - duration: { - "d_us": "forever", - }, - refreshable: true, - }, - }); - instance.setNewAccessToken(undefined, "another_token" as AccessToken); - }, - ({ instance, management, admin }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - env.addRequestExpectation(API_CREATE_INSTANCE, { - // auth: "another_token", - request: { - id: "new_instance_id", - } as MerchantBackend.Instances.InstanceConfigurationMessage, - }); - - admin.createInstance({ - id: "new_instance_id", - } as MerchantBackend.Instances.InstanceConfigurationMessage); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); diff --git a/packages/auditor-backoffice-ui/src/context/backend.ts b/packages/auditor-backoffice-ui/src/context/backend.ts index b13b92c42..ce321c3e6 100644 --- a/packages/auditor-backoffice-ui/src/context/backend.ts +++ b/packages/auditor-backoffice-ui/src/context/backend.ts @@ -17,54 +17,54 @@ /** * * @author Sebastian Javier Marchano (sebasjm) + * @author Nic Eigel */ -import { useMemoryStorage } from "@gnu-taler/web-util/browser"; import { createContext, h, VNode } from "preact"; import { useContext } from "preact/hooks"; -import { LoginToken } from "../declaration.js"; -import { useBackendDefaultToken, useBackendURL } from "../hooks/index.js"; +import { useBackendURL } from "../hooks/index.js"; interface BackendContextType { - url: string, - alreadyTriedLogin: boolean; - token?: LoginToken; - updateToken: (token: LoginToken | undefined) => void; + url: string, } const BackendContext = createContext<BackendContextType>({ - url: "", - alreadyTriedLogin: false, - token: undefined, - updateToken: () => null, + url: "", }); function useBackendContextState( - defaultUrl?: string, + defaultUrl?: string, ): BackendContextType { -const [url] = useBackendURL(defaultUrl); - //const url = "http://localhost:8081"; - const [token, updateToken] = useBackendDefaultToken(); - - return { - url, - token, - alreadyTriedLogin: token !== undefined, - updateToken, - }; + const [url] = useBackendURL(defaultUrl); + + return { + url, + }; } export const BackendContextProvider = ({ - children, - defaultUrl, -}: { - children: any; - defaultUrl?: string; + children, + defaultUrl, + }: { + children: any; + defaultUrl?: string; }): VNode => { - const value = useBackendContextState(defaultUrl); + const value = useBackendContextState(defaultUrl); - return h(BackendContext.Provider, { value, children }); + return h(BackendContext.Provider, { value, children }); }; + + export const useBackendContext = (): BackendContextType => - useContext(BackendContext); + useContext(BackendContext); + +interface BackendTokenType { + token: string; +} + +const BackendTokenContext = createContext<BackendTokenType>({} as any); + +export const BackendTokenContextProvider = BackendTokenContext.Provider; + +export const useBackendTokenContext = (): BackendTokenType => useContext(BackendTokenContext);
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/context/config.ts b/packages/auditor-backoffice-ui/src/context/config.ts index def45ea64..58ee5a594 100644 --- a/packages/auditor-backoffice-ui/src/context/config.ts +++ b/packages/auditor-backoffice-ui/src/context/config.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. + (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 @@ -21,12 +21,9 @@ import { createContext } from "preact"; import { useContext } from "preact/hooks"; +import { AuditorBackend } from "../declaration.js"; -interface Type { - currency: string; - version: string; -} -const Context = createContext<Type>(null!); +const Context = createContext<AuditorBackend.VersionResponse>(null!); export const ConfigContextProvider = Context.Provider; -export const useConfigContext = (): Type => useContext(Context); +export const useConfigContext = (): AuditorBackend.VersionResponse => useContext(Context); diff --git a/packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx b/packages/auditor-backoffice-ui/src/context/entity.ts index 9a445eb32..8181931c4 100644 --- a/packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx +++ b/packages/auditor-backoffice-ui/src/context/entity.ts @@ -17,25 +17,31 @@ /** * * @author Sebastian Javier Marchano (sebasjm) + * @author Nic Eigel */ -import { useFormContext } from "./FormProvider.js"; +import { createContext } from "preact"; +import { useContext } from "preact/hooks"; -interface Use { - hasError?: boolean; +interface EntityType { + title: string; + path: string; + endpoint: string; + entity: any; } -export function useGroupField<T>(name: keyof T): Use { - const f = useFormContext<T>(); - if (!f) return {}; +const EntityContext = createContext<EntityType>({} as any); - return { - hasError: readField(f.errors, String(name)), - }; +export const EntityContextProvider = EntityContext.Provider; + +export const useEntityContext = (): EntityType => useContext(EntityContext); + +interface EntityDataType { + data: any; } -const readField = (object: any, name: string) => { - return name - .split(".") - .reduce((prev, current) => prev && prev[current], object); -}; +const EntityDataContext = createContext<EntityDataType>({} as any); + +export const EntityDataContextProvider = EntityDataContext.Provider; + +export const useEntityDataContext = (): EntityDataType => useContext(EntityDataContext); diff --git a/packages/auditor-backoffice-ui/src/custom.d.ts b/packages/auditor-backoffice-ui/src/custom.d.ts index 34522a2dd..e693c2951 100644 --- a/packages/auditor-backoffice-ui/src/custom.d.ts +++ b/packages/auditor-backoffice-ui/src/custom.d.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. + (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 diff --git a/packages/auditor-backoffice-ui/src/declaration.d.ts b/packages/auditor-backoffice-ui/src/declaration.d.ts index 0c6f599f7..a8cdee53c 100644 --- a/packages/auditor-backoffice-ui/src/declaration.d.ts +++ b/packages/auditor-backoffice-ui/src/declaration.d.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. + (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 @@ -17,6 +17,7 @@ /** * * @author Sebastian Javier Marchano (sebasjm) + * @author Nic Eigel */ type HashCode = string; @@ -25,7 +26,7 @@ type EddsaSignature = string; type WireTransferIdentifierRawP = string; type RelativeTime = TalerProtocolDuration; type ImageDataUrl = string; -type MerchantUserType = "business" | "individual"; +type AuditorUserType = "business" | "individual"; export interface WithId { @@ -38,9 +39,11 @@ interface Timestamp { // never happen. t_s: number | "never"; } + interface TalerProtocolDuration { d_us: number | "forever"; } + interface Duration { d_ms: number | "forever"; } @@ -53,263 +56,51 @@ type Amount = string; type UUID = string; type Integer = number; -interface WireAccount { - // payto:// URI identifying the account and wire method - payto_uri: string; - - // URI to convert amounts from or to the currency used by - // this wire account of the exchange. Missing if no - // conversion is applicable. - conversion_url?: string; - - // Restrictions that apply to bank accounts that would send - // funds to the exchange (crediting this exchange bank account). - // Optional, empty array for unrestricted. - credit_restrictions: AccountRestriction[]; - - // Restrictions that apply to bank accounts that would receive - // funds from the exchange (debiting this exchange bank account). - // Optional, empty array for unrestricted. - debit_restrictions: AccountRestriction[]; - - // Signature using the exchange's offline key over - // a TALER_MasterWireDetailsPS - // with purpose TALER_SIGNATURE_MASTER_WIRE_DETAILS. - master_sig: EddsaSignature; -} - -type AccountRestriction = RegexAccountRestriction | DenyAllAccountRestriction; - -// Account restriction that disables this type of -// account for the indicated operation categorically. -interface DenyAllAccountRestriction { - type: "deny"; -} - -// Accounts interacting with this type of account -// restriction must have a payto://-URI matching -// the given regex. -interface RegexAccountRestriction { - type: "regex"; - - // Regular expression that the payto://-URI of the - // partner account must follow. The regular expression - // should follow posix-egrep, but without support for character - // classes, GNU extensions, back-references or intervals. See - // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html - // for a description of the posix-egrep syntax. Applications - // may support regexes with additional features, but exchanges - // must not use such regexes. - payto_regex: string; - - // Hint for a human to understand the restriction - // (that is hopefully easier to comprehend than the regex itself). - human_hint: string; - - // Map from IETF BCP 47 language tags to localized - // human hints. - human_hint_i18n?: { [lang_tag: string]: string }; -} -interface LoginToken { - token: string, - expiration: Timestamp, -} -// token used to get loginToken -// must forget after used -declare const __ac_token: unique symbol; -type AccessToken = string & { - [__ac_token]: true; -}; - -export namespace ExchangeBackend { - interface WireResponse { - // Master public key of the exchange, must match the key returned in /keys. - master_public_key: EddsaPublicKey; - - // Array of wire accounts operated by the exchange for - // incoming wire transfers. - accounts: WireAccount[]; - - // Object mapping names of wire methods (i.e. "sepa" or "x-taler-bank") - // to wire fees. - fees: { method: AggregateTransferFee }; - } - interface AggregateTransferFee { - // Per transfer wire transfer fee. - wire_fee: Amount; - - // Per transfer closing fee. - closing_fee: Amount; - - // What date (inclusive) does this fee go into effect? - // The different fees must cover the full time period in which - // any of the denomination keys are valid without overlap. - start_date: Timestamp; - - // What date (exclusive) does this fee stop going into effect? - // The different fees must cover the full time period in which - // any of the denomination keys are valid without overlap. - end_date: Timestamp; - - // Signature of TALER_MasterWireFeePS with - // purpose TALER_SIGNATURE_MASTER_WIRE_FEES. - sig: EddsaSignature; - } -} export namespace AuditorBackend { - interface ErrorDetail { - // Numeric error code unique to the condition. - // The other arguments are specific to the error value reported here. - code: number; + interface DepositConfirmation { + // identifier + deposit_confirmation_serial_id: number; - // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ... - // Should give a human-readable hint about the error's nature. Optional, may change without notice! - hint?: string; + h_contract_terms: string; - // Optional detail about the specific input value that failed. May change without notice! - detail?: string; + h_policy: string; - // Name of the parameter that was bogus (if applicable). - parameter?: string; + h_wire: string; - // Path to the argument that was bogus (if applicable). - path?: string; + exchange_timestamp: string; - // Offset of the argument that was bogus (if applicable). - offset?: string; + refund_deadline: string; - // Index of the argument that was bogus (if applicable). - index?: string; + wire_deadline: string; - // Name of the object that was bogus (if applicable). - object?: string; + total_without_fee: string; - // Name of the currency than was problematic (if applicable). - currency?: string; + coin_pubs: string; - // Expected type (if applicable). - type_expected?: string; + coin_sigs: string; - // Type that was provided instead (if applicable). - type_actual?: string; - } - interface Exchange { - // the exchange's base URL - url: string; - - // master public key of the exchange - master_pub: EddsaPublicKey; - } - namespace DepositConfirmation { - // POST /deposit-confirmation - interface ProductAddDetail { - // product ID to use. - product_id: string; - - // Human-readable product description. - description: string; + merchant_pub: string; - // Map from IETF BCP 47 language tags to localized descriptions - description_i18n: { [lang_tag: string]: string }; + merchant_sig: string; - // unit in which the product is measured (liters, kilograms, packages, etc.) - unit: string; + exchange_pub: string; - // The price for one unit of the product. Zero is used - // to imply that this product is not sold separately, or - // that the price is not fixed, and must be supplied by the - // front-end. If non-zero, this price MUST include applicable - // taxes. - price: Amount; + exchange_sig: string; - // An optional base64-encoded product image - image: ImageDataUrl; + suppressed: string; - // a list of taxes paid by the merchant for one unit of this product - taxes: Tax[]; - - // Number of units of the product in stock in sum in total, - // including all existing sales ever. Given in product-specific - // units. - // A value of -1 indicates "infinite" (i.e. for "electronic" books). - total_stock: Integer; - - // Identifies where the product is in stock. - address: Location; - - // Identifies when we expect the next restocking to happen. - next_restock?: Timestamp; - - // Minimum age buyer must have (in years). Default is 0. - minimum_age?: Integer; - } - // PATCH /private/products/$PRODUCT_ID - interface ProductPatchDetail { - // Human-readable product description. - description: string; - - // Map from IETF BCP 47 language tags to localized descriptions - description_i18n: { [lang_tag: string]: string }; - - // unit in which the product is measured (liters, kilograms, packages, etc.) - unit: string; - - // The price for one unit of the product. Zero is used - // to imply that this product is not sold separately, or - // that the price is not fixed, and must be supplied by the - // front-end. If non-zero, this price MUST include applicable - // taxes. - price: Amount; - - // An optional base64-encoded product image - image: ImageDataUrl; - - // a list of taxes paid by the merchant for one unit of this product - taxes: Tax[]; - - // Number of units of the product in stock in sum in total, - // including all existing sales ever. Given in product-specific - // units. - // A value of -1 indicates "infinite" (i.e. for "electronic" books). - total_stock: Integer; - - // Number of units of the product that were lost (spoiled, stolen, etc.) - total_lost: Integer; - - // Identifies where the product is in stock. - address: Location; - - // Identifies when we expect the next restocking to happen. - next_restock?: Timestamp; - - // Minimum age buyer must have (in years). Default is 0. - minimum_age?: Integer; - } - - // GET /deposit-confirmation - interface DepositConfirmationList { - depositConfirmations: DepositConfirmation []; - } - interface DepositConfirmation { - serial_id: string; - timestamp: string; - refund_deadline: string; - wire_deadline: string; - amount_without_fee: string; - } + ancient: string; + } - // GET /deposit-confirmation/$SERIAL_ID - interface DepositConfirmationDetail { - serial_id: string; - timestamp: string; - refund_deadline: string; - wire_deadline: string; - amount_without_fee: string; - } + interface Config { + name: string; + version: string; + implementation: string; + currency: string; + auditor_public_key: string; + exchange_master_public_key: string; } -} -export namespace MerchantBackend { interface ErrorDetail { // Numeric error code unique to the condition. // The other arguments are specific to the error value reported here. @@ -347,1447 +138,618 @@ export namespace MerchantBackend { type_actual?: string; } - // Delivery location, loosely modeled as a subset of - // ISO20022's PostalAddress25. - interface Tax { - // the name of the tax - name: string; - - // amount paid in tax - tax: Amount; - } - - interface Auditor { - // official name - name: string; - - // Auditor's public key - auditor_pub: EddsaPublicKey; - - // Base URL of the auditor - url: string; - } - interface Exchange { - // the exchange's base URL - url: string; - - // master public key of the exchange - master_pub: EddsaPublicKey; - } - - interface Product { - // merchant-internal identifier for the product. - product_id?: string; - - // Human-readable product description. - description: string; - - // Map from IETF BCP 47 language tags to localized descriptions - description_i18n?: { [lang_tag: string]: string }; - - // The number of units of the product to deliver to the customer. - quantity: Integer; - - // The unit in which the product is measured (liters, kilograms, packages, etc.) - unit: string; - - // The price of the product; this is the total price for quantity times unit of this product. - price?: Amount; - - // An optional base64-encoded product image - image: ImageDataUrl; - - // a list of taxes paid by the merchant for this product. Can be empty. - taxes: Tax[]; - - // time indicating when this product should be delivered - delivery_date?: TalerProtocolTimestamp; - - // Minimum age buyer must have (in years). Default is 0. - minimum_age?: Integer; - } - interface Merchant { - // label for a location with the business address of the merchant - address: Location; - - // the merchant's legal name of business - name: string; - - // label for a location that denotes the jurisdiction for disputes. - // Some of the typical fields for a location (such as a street address) may be absent. - jurisdiction: Location; - } - interface VersionResponse { // libtool-style representation of the Merchant protocol version, see // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning // The format is "current:revision:age". - version: string; - // Name of the protocol. - name: "taler-merchant"; - // Currency supported by this backend. + name: "taler-auditor"; + version: string; + + // Default (!) currency supported by this backend. + // This is the currency that the backend should + // suggest by default to the user when entering + // amounts. See currencies for a list of + // supported currencies and how to render them. + implementation: string; currency: string; + auditor_public_key: string; + exchange_master_public_key: string; + + // How services should render currencies supported + // by this backend. Maps + // currency codes (e.g. "EUR" or "KUDOS") to + // the respective currency specification. + // All currencies in this map are supported by + // the backend. Note that the actual currency + // specifications are a *hint* for applications + // that would like *advice* on how to render amounts. + // Applications *may* ignore the currency specification + // if they know how to render currencies that they are + // used with. + //currencies: { currency: CurrencySpecification }; + + // Array of exchanges trusted by the merchant. + // Since protocol v6. + // exchanges: ExchangeConfigInfo[]; } - interface Location { - // Nation with its own government. - country?: string; - - // Identifies a subdivision of a country such as state, region, county. - country_subdivision?: string; - - // Identifies a subdivision within a country sub-division. - district?: string; - - // Name of a built-up area, with defined boundaries, and a local government. - town?: string; - // Specific location name within the town. - town_location?: string; - - // Identifier consisting of a group of letters and/or numbers that - // is added to a postal address to assist the sorting of mail. - post_code?: string; - - // Name of a street or thoroughfare. - street?: string; - - // Name of the building or house. - building_name?: string; - - // Number that identifies the position of a building on a street. - building_number?: string; - - // Free-form address lines, should not exceed 7 elements. - address_lines?: string[]; + export interface TokenResponse { + null; } - namespace Instances { - //POST /private/instances/$INSTANCE/auth - interface InstanceAuthConfigurationMessage { - // Type of authentication. - // "external": The mechant backend does not do - // any authentication checks. Instead an API - // gateway must do the authentication. - // "token": The merchant checks an auth token. - // See "token" for details. - method: "external" | "token"; - - // For method "external", this field is mandatory. - // The token MUST begin with the string "secret-token:". - // After the auth token has been set (with method "token"), - // the value must be provided in a "Authorization: Bearer $token" - // header. - token?: string; - } - //POST /private/instances - interface InstanceConfigurationMessage { - // Name of the merchant instance to create (will become $INSTANCE). - id: string; - - // Merchant name corresponding to this instance. - name: string; - - // Type of the user (business or individual). - // Defaults to 'business'. Should become mandatory field - // in the future, left as optional for API compatibility for now. - user_type?: MerchantUserType; - - // Merchant email for customer contact. - email?: string; - - // Merchant public website. - website?: string; - - // Merchant logo. - logo?: ImageDataUrl; - - // "Authentication" header required to authorize management access the instance. - // Optional, if not given authentication will be disabled for - // this instance (hopefully authentication checks are still - // done by some reverse proxy). - auth: InstanceAuthConfigurationMessage; - // 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; + namespace Default { + interface ObjectResponse { + object: AnyEntry[]; } + } - // PATCH /private/instances/$INSTANCE - interface InstanceReconfigurationMessage { - - // Merchant name corresponding to this instance. - name: string; - - // Type of the user (business or individual). - // Defaults to 'business'. Should become mandatory field - // in the future, left as optional for API compatibility for now. - 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). - 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; + namespace AmountArithmeticInconsistency { - // If the frontend does NOT specify a payment deadline, how long should - // offers we make be valid by default? - default_pay_delay: RelativeTime; + class ClassAmountArithmeticInconsistency { + data: AmountArithmeticInconsistencyDetail[]; } - // GET /private/instances - interface InstancesResponse { - // List of instances that are present in the backend (see Instance) - instances: Instance[]; + interface SummaryResponse { + amount_arithmetic_inconsistency: AmountArithmeticInconsistencyDetail[]; } - interface Instance { - // Merchant name corresponding to this instance. - name: string; - - // 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) - id: string; - - // Public key of the merchant/instance, in Crockford Base32 encoding. - merchant_pub: EddsaPublicKey; - - // List of the payment targets supported by this instance. Clients can - // specify the desired payment target in /order requests. Note that - // front-ends do not have to support wallets selecting payment targets. - payment_targets: string[]; - - // Has this instance been deleted (but not purged)? - deleted: boolean; + interface AmountArithmeticInconsistencyDetail { + row_id: number; + operation: string; + exchange_amount: string; + auditor_amount: string; + profitable: boolean; + suppressed: 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"; - }; + namespace BadSigLoss { + class ClassBadSigLoss { + data: BadSigLossDetail[]; } - // DELETE /private/instances/$INSTANCE - interface LoginTokenRequest { - // Scope of the token (which kinds of operations it will allow) - scope: "readonly" | "write"; - - // Server may impose its own upper bound - // on the token validity duration - duration?: RelativeTime; - // Can this token be refreshed? - // Defaults to false. - refreshable?: boolean; + interface SummaryResponse { + amount_arithmetic_inconsistency: BadSigLossDetail[]; } - interface LoginTokenSuccessResponse { - // The login token that can be used to access resources - // that are in scope for some time. Must be prefixed - // with "Bearer " when used in the "Authorization" HTTP header. - // Will already begin with the RFC 8959 prefix. - token: string; - // Scope of the token (which kinds of operations it will allow) - scope: "readonly" | "write"; - - // Server may impose its own upper bound - // on the token validity duration - expiration: Timestamp; - - // Can this token be refreshed? - refreshable: boolean; + interface BadSigLossDetail + { + row_id: number; + operation: string; + loss: string; + operation_specific_pub: string; + suppressed: boolean; } } - namespace KYC { - //GET /private/instances/$INSTANCE/kyc - interface AccountKycRedirects { - // Array of pending KYCs. - pending_kycs: MerchantAccountKycRedirect[]; + namespace Balance { - // Array of exchanges with no reply. - timeout_kycs: ExchangeKycTimeout[]; + class ClassBalance { + // List of products that are present in the inventory + data: BalanceDetail[]; } - interface MerchantAccountKycRedirect { - // URL that the user should open in a browser to - // proceed with the KYC process (as returned - // by the exchange's /kyc-check/ endpoint). - // Optional, missing if the account is blocked - // due to AML and not due to KYC. - kyc_url?: string; - // Base URL of the exchange this is about. - exchange_url: string; + interface SummaryResponse { + // List of products that are present in the inventory + balances: BalanceDetail[]; + } - // AML status of the account. - aml_status: number; + interface BalanceDetail { + // identifier + row_id: number; - // Our bank wire account this is about. - payto_uri: string; - } - interface ExchangeKycTimeout { - // Base URL of the exchange this is about. - exchange_url: string; + balance_key: string; - // Numeric error code indicating errors the exchange - // returned, or TALER_EC_INVALID for none. - exchange_code: number; + balance_value: string; - // HTTP status code returned by the exchange when we asked for - // information about the KYC status. - // 0 if there was no response at all. - exchange_http_status: number; + suppressed: boolean; } - } - namespace BankAccounts { - - interface AccountAddDetails { - - // payto:// URI of the account. - payto_uri: string; - - // 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; - + namespace ClosureLag { + class ClassClosureLag { + // List of products that are present in the inventory + data: ClosureLagDetail[]; } - type FacadeCredentials = - | NoFacadeCredentials - | BasicAuthFacadeCredentials; - - interface NoFacadeCredentials { - type: "none"; + interface SummaryResponse { + // List of products that are present in the inventory + closure_lags: ClosureLagDetail[]; } - interface BasicAuthFacadeCredentials { - type: "basic"; - - // Username to use to authenticate - username: string; - - // Password to use to authenticate - password: string; + interface ClosureLagDetail { + row_id: number; + amount: string; + deadline: number; + wtid: number; + account: string; + suppressed: boolean; } + } - interface AccountAddResponse { - // Hash over the wire details (including over the salt). - h_wire: HashCode; - - // Salt used to compute h_wire. - salt: HashCode; + namespace CoinInconsistency { + class ClassCoinInconsistency { + data: CoinInconsistencyDetail[]; } - 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 SummaryResponse { + amount_arithmetic_inconsistency: CoinInconsistencyDetail[]; } - - interface AccountsSummaryResponse { - - // List of accounts that are known for the instance. - accounts: BankAccountEntry[]; + interface CoinInconsistencyDetail + { + row_id: number; + operation: string; + exchange_amount: string; + auditor_amount: string; + coin_pub: string; + profitable: boolean; + suppressed: boolean; } + } - interface BankAccountEntry { - // payto:// URI of the account. - payto_uri: string; - - // Hash over the wire details (including over the salt) - h_wire: HashCode; - - // salt used to compute h_wire - salt: HashCode; - - // 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. - credit_facade_credentials?: FacadeCredentials; + namespace DenominationKeyValidityWithdrawInconsistency { + class ClassDenominationKeyValidityWithdrawInconsistency { + data: DenominationKeyValidityWithdrawInconsistencyDetail[]; + } - // true if this account is active, - // false if it is historic. - active: boolean; + interface SummaryResponse { + responseData: DenominationKeyValidityWithdrawInconsistencyDetail[]; } + interface DenominationKeyValidityWithdrawInconsistencyDetail + { + row_id: number; + operation: string; + loss: string; + operation_specific_pub: string; + suppressed: boolean; + } } - namespace Products { - // POST /private/products - interface ProductAddDetail { - // product ID to use. - product_id: string; - - // Human-readable product description. - description: string; - - // Map from IETF BCP 47 language tags to localized descriptions - description_i18n: { [lang_tag: string]: string }; - - // unit in which the product is measured (liters, kilograms, packages, etc.) - unit: string; - - // The price for one unit of the product. Zero is used - // to imply that this product is not sold separately, or - // that the price is not fixed, and must be supplied by the - // front-end. If non-zero, this price MUST include applicable - // taxes. - price: Amount; - - // An optional base64-encoded product image - image: ImageDataUrl; - - // a list of taxes paid by the merchant for one unit of this product - taxes: Tax[]; - - // Number of units of the product in stock in sum in total, - // including all existing sales ever. Given in product-specific - // units. - // A value of -1 indicates "infinite" (i.e. for "electronic" books). - total_stock: Integer; - - // Identifies where the product is in stock. - address: Location; - - // Identifies when we expect the next restocking to happen. - next_restock?: Timestamp; - - // Minimum age buyer must have (in years). Default is 0. - minimum_age?: Integer; + namespace DenominationPending { + class ClassDenominationPending { + data: DenominationPendingDetail[]; } - // PATCH /private/products/$PRODUCT_ID - interface ProductPatchDetail { - // Human-readable product description. - description: string; - - // Map from IETF BCP 47 language tags to localized descriptions - description_i18n: { [lang_tag: string]: string }; - // unit in which the product is measured (liters, kilograms, packages, etc.) - unit: string; - - // The price for one unit of the product. Zero is used - // to imply that this product is not sold separately, or - // that the price is not fixed, and must be supplied by the - // front-end. If non-zero, this price MUST include applicable - // taxes. - price: Amount; - - // An optional base64-encoded product image - image: ImageDataUrl; - - // a list of taxes paid by the merchant for one unit of this product - taxes: Tax[]; - - // Number of units of the product in stock in sum in total, - // including all existing sales ever. Given in product-specific - // units. - // A value of -1 indicates "infinite" (i.e. for "electronic" books). - total_stock: Integer; - - // Number of units of the product that were lost (spoiled, stolen, etc.) - total_lost: Integer; - - // Identifies where the product is in stock. - address: Location; - - // Identifies when we expect the next restocking to happen. - next_restock?: Timestamp; - - // Minimum age buyer must have (in years). Default is 0. - minimum_age?: Integer; + interface SummaryResponse { + responseData: DenominationPendingDetail[]; } - // GET /private/products - interface InventorySummaryResponse { - // List of products that are present in the inventory - products: InventoryEntry[]; + interface DenominationPendingDetail + { + denom_pub_hash: string; + denom_balance: string; + denom_loss: string; + num_issued: number; + denom_risk: string; + recoup_loss: string; + suppressed: boolean; } - interface InventoryEntry { - // Product identifier, as found in the product. - product_id: string; - } - - // GET /private/products/$PRODUCT_ID - interface ProductDetail { - // Human-readable product description. - description: string; - - // Map from IETF BCP 47 language tags to localized descriptions - description_i18n: { [lang_tag: string]: string }; - - // unit in which the product is measured (liters, kilograms, packages, etc.) - unit: string; - - // The price for one unit of the product. Zero is used - // to imply that this product is not sold separately, or - // that the price is not fixed, and must be supplied by the - // front-end. If non-zero, this price MUST include applicable - // taxes. - price: Amount; - - // An optional base64-encoded product image - image: ImageDataUrl; - - // a list of taxes paid by the merchant for one unit of this product - taxes: Tax[]; - - // Number of units of the product in stock in sum in total, - // including all existing sales ever. Given in product-specific - // units. - // A value of -1 indicates "infinite" (i.e. for "electronic" books). - total_stock: Integer; - - // Number of units of the product that have already been sold. - total_sold: Integer; - - // Number of units of the product that were lost (spoiled, stolen, etc.) - total_lost: Integer; - - // Identifies where the product is in stock. - address: Location; - - // Identifies when we expect the next restocking to happen. - next_restock?: Timestamp; + } - // Minimum age buyer must have (in years). Default is 0. - minimum_age?: Integer; + namespace DenominationsWithoutSigs { + class ClassDenominationsWithoutSigs { + data: DenominationsWithoutSigsDetail[]; } - // POST /private/products/$PRODUCT_ID/lock - interface LockRequest { - // UUID that identifies the frontend performing the lock - // It is suggested that clients use a timeflake for this, - // see https://github.com/anthonynsimon/timeflake - lock_uuid: UUID; - - // How long does the frontend intend to hold the lock - duration: RelativeTime; - - // How many units should be locked? - quantity: Integer; + interface SummaryResponse { + responseData: DenominationsWithoutSigsDetail[]; } - // DELETE /private/products/$PRODUCT_ID + interface DenominationsWithoutSigsDetail + { + row_id: number; + denompub_h: string; + value: string; + start_time: number; + end_time: number; + suppressed: boolean; + } } - namespace Orders { - type MerchantOrderStatusResponse = - | CheckPaymentPaidResponse - | CheckPaymentClaimedResponse - | CheckPaymentUnpaidResponse; - interface CheckPaymentPaidResponse { - // The customer paid for this contract. - order_status: "paid"; - - // Was the payment refunded (even partially)? - refunded: boolean; - - // True if there are any approved refunds that the wallet has - // not yet obtained. - refund_pending: boolean; - - // Did the exchange wire us the funds? - wired: boolean; - - // Total amount the exchange deposited into our bank account - // for this contract, excluding fees. - deposit_total: Amount; - - // Numeric error code indicating errors the exchange - // encountered tracking the wire transfer for this purchase (before - // we even got to specific coin issues). - // 0 if there were no issues. - exchange_ec: number; - - // HTTP status code returned by the exchange when we asked for - // information to track the wire transfer for this purchase. - // 0 if there were no issues. - exchange_hc: number; - - // Total amount that was refunded, 0 if refunded is false. - refund_amount: Amount; - - // Contract terms. - contract_terms: ContractTerms; - - // The wire transfer status from the exchange for this order if - // available, otherwise empty array. - wire_details: TransactionWireTransfer[]; - - // Reports about trouble obtaining wire transfer details, - // empty array if no trouble were encountered. - wire_reports: TransactionWireReport[]; - - // The refund details for this order. One entry per - // refunded coin; empty array if there are no refunds. - refund_details: RefundDetails[]; - - // Status URL, can be used as a redirect target for the browser - // to show the order QR code / trigger the wallet. - order_status_url: string; + namespace DepositConfirmation { + class ClassDepositConfirmation{ + data: DepositConfirmationDetail[]; } - interface CheckPaymentClaimedResponse { - // A wallet claimed the order, but did not yet pay for the contract. - order_status: "claimed"; - // Contract terms. - contract_terms: ContractTerms; + interface SummaryResponse { + responseData: DepositConfirmationDetail[]; } - interface CheckPaymentUnpaidResponse { - // The order was neither claimed nor paid. - order_status: "unpaid"; - - // when was the order created - creation_time: Timestamp; - - // Order summary text. - summary: string; - // Total amount of the order (to be paid by the customer). - total_amount: Amount; - - // URI that the wallet must process to complete the payment. - taler_pay_uri: string; - - // Alternative order ID which was paid for already in the same session. - // Only given if the same product was purchased before in the same session. - already_paid_order_id?: string; - - // Fulfillment URL of an already paid order. Only given if under this - // session an already paid order with a fulfillment URL exists. - already_paid_fulfillment_url?: string; - - // Status URL, can be used as a redirect target for the browser - // to show the order QR code / trigger the wallet. - order_status_url: string; - - // We do we NOT return the contract terms here because they may not - // exist in case the wallet did not yet claim them. + interface DepositConfirmationDetail { + deposit_confirmation_serial_id: number; + h_contract_terms: string; + h_policy: string; + h_wire: string; + exchange_timestamp: string; + refund_deadline: string; + wire_deadline: string; + total_without_fee: string; + coin_pubs: string; + coin_sigs: string; + merchant_pub: string; + merchant_sig: string; + exchange_pub: string; + exchange_sig: string; + suppressed: string; + ancient: string; } - interface RefundDetails { - // Reason given for the refund. - reason: string; - - // When was the refund approved. - timestamp: Timestamp; - - // Set to true if a refund is still available for the wallet for this payment. - pending: boolean; + } - // Total amount that was refunded (minus a refund fee). - amount: Amount; + namespace Emergency { + class ClassEmergency{ + data: EmergencyDetail[]; } - interface TransactionWireTransfer { - // Responsible exchange. - exchange_url: string; - - // 32-byte wire transfer identifier. - wtid: Base32; - // Execution time of the wire transfer. - execution_time: Timestamp; - - // Total amount that has been wire transferred - // to the merchant. - amount: Amount; - - // Was this transfer confirmed by the merchant via the - // POST /transfers API, or is it merely claimed by the exchange? - confirmed: boolean; + interface SummaryResponse { + responseData: EmergencyDetail[]; } - interface TransactionWireReport { - // Numerical error code. - code: number; - - // Human-readable error description. - hint: string; - // Numerical error code from the exchange. - exchange_ec: number; - - // HTTP status code received from the exchange. - exchange_hc: number; + interface EmergencyDetail + { + row_id: number; + denompub_h: string; + denom_risk: string; + denom_loss: string; + deposit_start: number; + deposit_end: number; + value: string; + } + } - // Public key of the coin for which we got the exchange error. - coin_pub: CoinPublicKey; + namespace EmergencyByCount { + class ClassEmergencyByCount{ + data: EmergencyByCountDetail[]; } - interface OrderHistory { - // timestamp-sorted array of all orders matching the query. - // The order of the sorting depends on the sign of delta. - orders: OrderHistoryEntry[]; + interface SummaryResponse { + responseData: EmergencyByCountDetail[]; } - interface OrderHistoryEntry { - // order ID of the transaction related to this entry. - order_id: string; - // row ID of the order in the database + interface EmergencyByCountDetail + { row_id: number; - - // when the order was created - timestamp: Timestamp; - - // the amount of money the order is for - amount: Amount; - - // the summary of the order - summary: string; - - // whether some part of the order is refundable, - // that is the refund deadline has not yet expired - // and the total amount refunded so far is below - // the value of the original transaction. - refundable: boolean; - - // whether the order has been paid or not - paid: boolean; + denompub_h: string; + num_issued: number; + num_known: number; + risk: string; + start: number; + deposit_end: number; + value: string; + suppressed: boolean; } + } - interface PostOrderRequest { - // The order must at least contain the minimal - // order detail, but can override all - order: Order; - - // if set, the backend will then set the refund deadline to the current - // time plus the specified delay. If it's not set, refunds will not be - // possible. - refund_delay?: RelativeTime; - - // specifies the payment target preferred by the client. Can be used - // to select among the various (active) wire methods supported by the instance. - payment_target?: string; - - // specifies that some products are to be included in the - // order from the inventory. For these inventory management - // is performed (so the products must be in stock) and - // details are completed from the product data of the backend. - inventory_products?: MinimalInventoryProduct[]; - - // Specifies a lock identifier that was used to - // lock a product in the inventory. Only useful if - // manage_inventory is set. Used in case a frontend - // reserved quantities of the individual products while - // the shopping card was being built. Multiple UUIDs can - // be used in case different UUIDs were used for different - // products (i.e. in case the user started with multiple - // shopping sessions that were combined during checkout). - lock_uuids?: UUID[]; - - // Should a token for claiming the order be generated? - // False can make sense if the ORDER_ID is sufficiently - // high entropy to prevent adversarial claims (like it is - // if the backend auto-generates one). Default is 'true'. - create_token?: boolean; - - // OTP device ID to associate with the order. - // This parameter is optional. - otp_id?: string; + namespace FeeTimeInconsistency { + class ClassFeeTimeInconsistency{ + data: FeeTimeInconsistencyDetail[]; } - type Order = MinimalOrderDetail | ContractTerms; - - interface MinimalOrderDetail { - // Amount to be paid by the customer - amount: Amount; - // Short summary of the order - summary: string; - - // URL that will show that the order was successful after - // it has been paid for. Optional. When POSTing to the - // merchant, the placeholder "${ORDER_ID}" will be - // replaced with the actual order ID (useful if the - // order ID is generated server-side and needs to be - // in the URL). - fulfillment_url?: string; + interface SummaryResponse { + responseData: FeeTimeInconsistencyDetail[]; } - interface MinimalInventoryProduct { - // Which product is requested (here mandatory!) - product_id: string; - - // How many units of the product are requested - quantity: Integer; + interface FeeTimeInconsistencyDetail + { + row_id: number; + type: string; + time: string; + diagnostic: string; + suppressed: boolean; } - interface PostOrderResponse { - // Order ID of the response that was just created - order_id: string; + } - // Token that authorizes the wallet to claim the order. - // Provided only if "create_token" was set to 'true' - // in the request. - token?: ClaimToken; + namespace HistoricDenominationRevenue { + class ClassHistoricDenominationRevenue { + data: HistoricDenominationRevenueDetail[]; } - interface OutOfStockResponse { - // Product ID of an out-of-stock item - product_id: string; - - // Requested quantity - requested_quantity: Integer; - - // Available quantity (must be below requested_quanitity) - available_quantity: Integer; - // When do we expect the product to be again in stock? - // Optional, not given if unknown. - restock_expected?: Timestamp; - } - - interface ForgetRequest { - // Array of valid JSON paths to forgettable fields in the order's - // contract terms. - fields: string[]; + interface SummaryResponse { + responseData: HistoricDenominationRevenueDetail[]; } - interface RefundRequest { - // Amount to be refunded - refund: Amount; - // Human-readable refund justification - reason: string; - } - interface MerchantRefundResponse { - // URL (handled by the backend) that the wallet should access to - // trigger refund processing. - // taler://refund/... - taler_refund_uri: string; - - // Contract hash that a client may need to authenticate an - // HTTP request to obtain the above URI in a wallet-friendly way. - h_contract: HashCode; + interface HistoricDenominationRevenueDetail + { + denom_pub_hash: string; + revenue_timestamp: number; + revenue_balance: string; + loss_balance: string; + suppressed: boolean; } } - namespace Rewards { - // GET /private/reserves - interface RewardReserveStatus { - // Array of all known reserves (possibly empty!) - reserves: ReserveStatusEntry[]; + namespace HistoricReserveSummary { + class ClassHistoricReserveSummary { + data: HistoricReserveSummaryDetail[]; } - interface ReserveStatusEntry { - // Public key of the reserve - reserve_pub: EddsaPublicKey; - - // Timestamp when it was established - creation_time: Timestamp; - // Timestamp when it expires - expiration_time: Timestamp; - - // Initial amount as per reserve creation call - merchant_initial_amount: Amount; - - // Initial amount as per exchange, 0 if exchange did - // not confirm reserve creation yet. - exchange_initial_amount: Amount; - - // Amount picked up so far. - pickup_amount: Amount; - - // Amount approved for rewards that exceeds the pickup_amount. - committed_amount: Amount; - - // Is this reserve active (false if it was deleted but not purged) - active: boolean; + interface SummaryResponse { + responseData: HistoricReserveSummaryDetail[]; } - interface ReserveCreateRequest { - // Amount that the merchant promises to put into the reserve - initial_balance: Amount; - - // Exchange the merchant intends to use for reward - exchange_url: string; - - // Desired wire method, for example "iban" or "x-taler-bank" - wire_method: string; + interface HistoricReserveSummaryDetail + { + denom_pub_hash: string; + revenue_timestamp: number; + revenue_balance: string; + loss_balance: string; + suppressed: boolean; } - interface ReserveCreateConfirmation { - // Public key identifying the reserve - reserve_pub: EddsaPublicKey; + } - // Wire accounts of the exchange where to transfer the funds. - accounts: WireAccount[]; + namespace MisattributionInInconsistency { + class ClassMisattributionInInconsistency { + data: MisattributionInInconsistencyDetail[]; } - interface RewardCreateRequest { - // Amount that the customer should be reward - amount: Amount; - - // Justification for giving the reward - justification: string; - // URL that the user should be directed to after rewarding, - // will be included in the reward_token. - next_url: string; + interface SummaryResponse { + responseData: MisattributionInInconsistencyDetail[]; } - interface RewardCreateConfirmation { - // Unique reward identifier for the reward that was created. - reward_id: HashCode; - // taler://reward URI for the reward - taler_reward_uri: string; - - // URL that will directly trigger processing - // the reward when the browser is redirected to it - reward_status_url: string; - - // when does the reward expire - reward_expiration: Timestamp; + interface MisattributionInInconsistencyDetail + { + row_id: number; + amount: string; + bank_row: number; + reserve_pub: string; + suppressed: boolean; } + } - interface ReserveDetail { - // Timestamp when it was established. - creation_time: Timestamp; - - // Timestamp when it expires. - expiration_time: Timestamp; - - // Initial amount as per reserve creation call. - merchant_initial_amount: Amount; - - // Initial amount as per exchange, 0 if exchange did - // not confirm reserve creation yet. - exchange_initial_amount: Amount; - - // Amount picked up so far. - pickup_amount: Amount; - - // Amount approved for rewards that exceeds the pickup_amount. - committed_amount: Amount; - - // Array of all rewards created by this reserves (possibly empty!). - // Only present if asked for explicitly. - rewards?: RewardStatusEntry[]; - - // Is this reserve active (false if it was deleted but not purged)? - active: boolean; - - // Array of wire accounts of the exchange that could - // be used to fill the reserve, can be NULL - // if the reserve is inactive or was already filled - accounts?: WireAccount[]; - - // URL of the exchange hosting the reserve, - // NULL if the reserve is inactive - exchange_url: string; + namespace Progress { + class ClassProgress { + data: ProgressDetail[]; } - interface RewardStatusEntry { - // Unique identifier for the reward. - reward_id: HashCode; - - // Total amount of the reward that can be withdrawn. - total_amount: Amount; - - // Human-readable reason for why the reward was granted. - reason: string; + interface SummaryResponse { + responseData: ProgressDetail[]; } - interface RewardDetails { - // Amount that we authorized for this reward. - total_authorized: Amount; - - // Amount that was picked up by the user already. - total_picked_up: Amount; - - // Human-readable reason given when authorizing the reward. - reason: string; - - // Timestamp indicating when the reward is set to expire (may be in the past). - expiration: Timestamp; - - // Reserve public key from which the reward is funded. - reserve_pub: EddsaPublicKey; + interface ProgressDetail + { + progress_key: string; + progress_offset: number; + suppressed: boolean; + } + } - // Array showing the pickup operations of the wallet (possibly empty!). - // Only present if asked for explicitly. - pickups?: PickupDetail[]; + namespace PurseNotClosedInconsistency { + class ClassPurseNotClosedInconsistency { + data: PurseNotClosedInconsistencyDetail[]; } - interface PickupDetail { - // Unique identifier for the pickup operation. - pickup_id: HashCode; - // Number of planchets involved. - num_planchets: Integer; + interface SummaryResponse { + responseData: PurseNotClosedInconsistencyDetail[]; + } - // Total amount requested for this pickup_id. - requested_amount: Amount; + interface PurseNotClosedInconsistencyDetail + { + row_id: number; + purse_pub: string, + amount: string; + expiration_date: number; + suppressed: boolean; } } - namespace Transfers { - interface TransferList { - // list of all the transfers that fit the filter that we know - transfers: TransferDetails[]; + namespace Purses { + class ClassPurses { + data: PursesDetail[]; } - interface TransferDetails { - // how much was wired to the merchant (minus fees) - credit_amount: Amount; - - // raw wire transfer identifier identifying the wire transfer (a base32-encoded value) - wtid: string; - - // target account that received the wire transfer - payto_uri: string; - - // base URL of the exchange that made the wire transfer - exchange_url: string; - // Serial number identifying the transfer in the merchant backend. - // Used for filgering via offset. - transfer_serial_id: number; - - // Time of the execution of the wire transfer by the exchange, according to the exchange - // Only provided if we did get an answer from the exchange. - execution_time?: Timestamp; - - // True if we checked the exchange's answer and are happy with it. - // False if we have an answer and are unhappy, missing if we - // do not have an answer from the exchange. - verified?: boolean; - - // True if the merchant uses the POST /transfers API to confirm - // that this wire transfer took place (and it is thus not - // something merely claimed by the exchange). - confirmed?: boolean; + interface SummaryResponse { + responseData: PursesDetail[]; } - interface TransferInformation { - // how much was wired to the merchant (minus fees) - credit_amount: Amount; - - // raw wire transfer identifier identifying the wire transfer (a base32-encoded value) - wtid: WireTransferIdentifierRawP; - - // target account that received the wire transfer - payto_uri: string; - - // base URL of the exchange that made the wire transfer - exchange_url: string; + interface PursesDetail + { + auditor_purses_rowid: number; + purse_pub: string; + balance: string; + target: string, + expiration_date: number; + suppressed: boolean; } } - namespace OTP { - interface OtpDeviceAddDetails { - // Device ID to use. - otp_device_id: string; - - // Human-readable description for the device. - otp_device_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; + namespace RefreshesHanging { + class ClassRefreshesHanging { + data: RefreshesHangingDetail[]; } - interface OtpDevicePatchDetails { - // Human-readable description for the device. - otp_device_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 SummaryResponse { + responseData: RefreshesHangingDetail[]; } - interface OtpDeviceSummaryResponse { - // Array of devices that are present in our backend. - otp_devices: OtpDeviceEntry[]; + interface RefreshesHangingDetail + { + row_id: number; + amount: string; + coin_pub: string; + suppressed: boolean; } - interface OtpDeviceEntry { - // Device identifier. - otp_device_id: string; + } - // Human-readable description for the device. - device_description: string; + namespace ReserveBalanceInsufficientInconsistency { + class ClassReserveBalanceInsufficientInconsistency { + data: ReserveBalanceInsufficientInconsistencyDetail[]; } - 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; + interface SummaryResponse { + responseData: ReserveBalanceInsufficientInconsistencyDetail[]; } - + interface ReserveBalanceInsufficientInconsistencyDetail + { + row_id: number; + reserve_pub: string; + inconsistency_gain: boolean; + inconsistency_amount: string; + suppressed: boolean; + } } - namespace Template { - interface TemplateAddDetails { - // Template ID to use. - template_id: string; - // Human-readable description for the template. - template_description: string; - - // OTP device ID. - // This parameter is optional. - otp_id?: string; - - // Additional information in a separate template. - template_contract: TemplateContractDetails; + namespace ReserveBalanceSummaryWrongInconsistency { + class ClassReserveBalanceSummaryWrongInconsistency { + data: ReserveBalanceSummaryWrongInconsistencyDetail[]; } - interface TemplateContractDetails { - // Human-readable summary for the template. - summary?: string; - - // The price is imposed by the merchant and cannot be changed by the customer. - // This parameter is optional. - amount?: Amount; - // Minimum age buyer must have (in years). Default is 0. - minimum_age: Integer; - - // The time the customer need to pay before his order will be deleted. - // It is deleted if the customer did not pay and if the duration is over. - pay_duration: RelativeTime; + interface SummaryResponse { + responseData: ReserveBalanceSummaryWrongInconsistencyDetail[]; } - interface TemplatePatchDetails { - // Human-readable description for the template. - template_description: string; - - // OTP device ID. - // This parameter is optional. - otp_id?: string; - // Additional information in a separate template. - template_contract: TemplateContractDetails; + interface ReserveBalanceSummaryWrongInconsistencyDetail + { + row_id: number; + reserve_pub: string; + exchange_amount: string; + auditor_amount: string; + suppressed: boolean; } + } - interface TemplateSummaryResponse { - // List of templates that are present in our backend. - templates: TemplateEntry[]; + namespace ReserveInInconsistency { + class ClassReserveInInconsistency { + data: ReserveInInconsistencyDetail[]; } - interface TemplateEntry { - // Template identifier, as found in the template. - template_id: string; - - // Human-readable description for the template. - template_description: string; + interface SummaryResponse { + responseData: ReserveInInconsistencyDetail[]; } - interface TemplateDetails { - // Human-readable description for the template. - template_description: string; - - // OTP device ID. - // This parameter is optional. - otp_id?: string; - - // Additional information in a separate template. - template_contract: TemplateContractDetails; + interface ReserveInInconsistencyDetail + { + row_id: number; + amount_exchange_expected: string; + amount_wired: string; + reserve_pub: string; + timestamp: number; + account: string; + diagnostic: string; + suppressed: boolean; } + } - interface UsingTemplateDetails { - // Subject of the template - summary?: string; + namespace ReserveNotClosedInconsistency { + class ClassReserveNotClosedInconsistency { + data: ReserveNotClosedInconsistencyDetail[]; + } - // The amount entered by the customer. - amount?: Amount; + interface SummaryResponse { + responseData: ReserveNotClosedInconsistencyDetail[]; } - interface UsingTemplateResponse { - // After enter the request. The user will be pay with a taler URL. - order_id: string; - token: string; + interface ReserveNotClosedInconsistencyDetail + { + row_id: number; + reserve_pub: string; + balance: string; + expiration_time: number; + diagnostic: string; + suppressed: boolean; } } - namespace Webhooks { - type MerchantWebhookType = "pay" | "refund"; - interface WebhookAddDetails { - // Webhook ID to use. - webhook_id: string; - - // The event of the webhook: why the webhook is used. - event_type: MerchantWebhookType; - - // URL of the webhook where the customer will be redirected. - url: string; + namespace Reserves { + class ClassReserves{ + data: ReservesDetail[]; + } - // Method used by the webhook - http_method: string; + interface SummaryResponse { + responseData: ReservesDetail[]; + } - // Header template of the webhook - header_template?: string; + interface ReservesDetail + { + auditor_reserves_rowid: number; + reserve_pub: string; + reserve_balance: string; + reserve_loss: string; + withdraw_fee_balance: string; + close_fee_balance: string; + purse_fee_balance: string; + open_fee_balance: string; + history_fee_balance: string; + expiration_date: number; + origin_account: string; + suppressed: boolean; + } + } - // Body template by the webhook - body_template?: string; + namespace RowInconsistency { + class ClassRowInconsistency { + data: RowInconsistencyDetail[]; } - interface WebhookPatchDetails { - // The event of the webhook: why the webhook is used. - event_type: string; - // URL of the webhook where the customer will be redirected. - url: string; + interface SummaryResponse { + responseData: RowInconsistencyDetail[]; + } - // Method used by the webhook - http_method: string; + interface RowInconsistencyDetail + { + row_id: number; + row_table: string; + diagnostic: string; + suppressed: boolean; + } + } - // Header template of the webhook - header_template?: string; + namespace RowMinorInconsistency { + class ClassRowMinorInconsistency { + data: RowMinorInconsistencyDetail[]; + } - // Body template by the webhook - body_template?: string; + interface SummaryResponse { + responseData: RowMinorInconsistencyDetail[]; } - interface WebhookSummaryResponse { - // List of webhooks that are present in our backend. - webhooks: WebhookEntry[]; + + interface RowMinorInconsistencyDetail + { + row_id: number; + row_table: string; + diagnostic: string; + suppressed: boolean; } - interface WebhookEntry { - // Webhook identifier, as found in the webhook. - webhook_id: string; + } - // The event of the webhook: why the webhook is used. - event_type: string; + namespace WireFormatInconsistency { + class ClassWireFormatInconsistency { + data: WireFormatInconsistencyDetail[]; } - interface WebhookDetails { - // The event of the webhook: why the webhook is used. - event_type: string; - // URL of the webhook where the customer will be redirected. - url: string; + interface SummaryResponse { + responseData: WireFormatInconsistencyDetail[]; + } - // Method used by the webhook - http_method: string; + interface WireFormatInconsistencyDetail + { + row_id: number; + amount: string; + wire_offset: string; + diagnostic: string; + suppressed: boolean; + } + } - // Header template of the webhook - header_template?: string; + namespace WireOutInconsistency { + class ClassWireOutInconsistency{ + data: WireOutInconsistencyDetail[]; + } - // Body template by the webhook - body_template?: string; + interface SummaryResponse { + responseData: WireOutInconsistencyDetail[]; } - } - interface ContractTerms { - // Human-readable description of the whole purchase - summary: string; - - // Map from IETF BCP 47 language tags to localized summaries - summary_i18n?: { [lang_tag: string]: string }; - - // Unique, free-form identifier for the proposal. - // Must be unique within a merchant instance. - // For merchants that do not store proposals in their DB - // before the customer paid for them, the order_id can be used - // by the frontend to restore a proposal from the information - // encoded in it (such as a short product identifier and timestamp). - order_id: string; - - // Total price for the transaction. - // The exchange will subtract deposit fees from that amount - // before transferring it to the merchant. - amount: Amount; - - // The URL for this purchase. Every time is is visited, the merchant - // will send back to the customer the same proposal. Clearly, this URL - // can be bookmarked and shared by users. - fulfillment_url?: string; - - // Maximum total deposit fee accepted by the merchant for this contract - max_fee: Amount; - - // List of products that are part of the purchase (see Product). - products: Product[]; - - // Time when this contract was generated - timestamp: TalerProtocolTimestamp; - - // After this deadline has passed, no refunds will be accepted. - refund_deadline: TalerProtocolTimestamp; - - // After this deadline, the merchant won't accept payments for the contact - pay_deadline: TalerProtocolTimestamp; - - // Transfer deadline for the exchange. Must be in the - // deposit permissions of coins used to pay for this order. - wire_transfer_deadline: TalerProtocolTimestamp; - - // Merchant's public key used to sign this proposal; this information - // is typically added by the backend Note that this can be an ephemeral key. - merchant_pub: EddsaPublicKey; - - // Base URL of the (public!) merchant backend API. - // Must be an absolute URL that ends with a slash. - merchant_base_url: string; - - // More info about the merchant, see below - merchant: Merchant; - - // The hash of the merchant instance's wire details. - h_wire: HashCode; - - // Wire transfer method identifier for the wire method associated with h_wire. - // The wallet may only select exchanges via a matching auditor if the - // exchange also supports this wire method. - // The wire transfer fees must be added based on this wire transfer method. - wire_method: string; - - // Any exchanges audited by these auditors are accepted by the merchant. - auditors: Auditor[]; - - // Exchanges that the merchant accepts even if it does not accept any auditors that audit them. - exchanges: Exchange[]; - - // Delivery location for (all!) products. - delivery_location?: Location; - - // Time indicating when the order should be delivered. - // May be overwritten by individual products. - delivery_date?: TalerProtocolTimestamp; - - // Nonce generated by the wallet and echoed by the merchant - // in this field when the proposal is generated. - nonce: string; - - // Specifies for how long the wallet should try to get an - // automatic refund for the purchase. If this field is - // present, the wallet should wait for a few seconds after - // the purchase and then automatically attempt to obtain - // a refund. The wallet should probe until "delay" - // after the payment was successful (i.e. via long polling - // or via explicit requests with exponential back-off). - // - // In particular, if the wallet is offline - // at that time, it MUST repeat the request until it gets - // one response from the merchant after the delay has expired. - // If the refund is granted, the wallet MUST automatically - // recover the payment. This is used in case a merchant - // knows that it might be unable to satisfy the contract and - // desires for the wallet to attempt to get the refund without any - // customer interaction. Note that it is NOT an error if the - // merchant does not grant a refund. - auto_refund?: RelativeTime; - - // Extra data that is only interpreted by the merchant frontend. - // Useful when the merchant needs to store extra information on a - // contract without storing it separately in their database. - extra?: any; - - // Minimum age buyer must have (in years). Default is 0. - minimum_age?: Integer; + interface WireOutInconsistencyDetail + { + row_id: number; + destination_account: string; + expected: string; + claimed: string; + suppressed: boolean; + } } -} +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/hooks/async.ts b/packages/auditor-backoffice-ui/src/hooks/async.ts deleted file mode 100644 index f22badc88..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/async.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - 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 { useState } from "preact/hooks"; - -export interface Options { - slowTolerance: number; -} - -export interface AsyncOperationApi<T> { - request: (...a: any) => void; - cancel: () => void; - data: T | undefined; - isSlow: boolean; - isLoading: boolean; - error: string | undefined; -} - -export function useAsync<T>( - fn?: (...args: any) => Promise<T>, - { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }, -): AsyncOperationApi<T> { - const [data, setData] = useState<T | undefined>(undefined); - const [isLoading, setLoading] = useState<boolean>(false); - const [error, setError] = useState<any>(undefined); - const [isSlow, setSlow] = useState(false); - - const request = async (...args: any) => { - if (!fn) return; - setLoading(true); - - const handler = setTimeout(() => { - setSlow(true); - }, tooLong); - - try { - const result = await fn(...args); - setData(result); - } catch (error) { - setError(error); - } - setLoading(false); - setSlow(false); - clearTimeout(handler); - }; - - function cancel(): void { - setLoading(false); - setSlow(false); - } - - return { - request, - cancel, - data, - isSlow, - isLoading, - error, - }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/backend.ts b/packages/auditor-backoffice-ui/src/hooks/backend.ts index 8d99546a8..4b0a5a828 100644 --- a/packages/auditor-backoffice-ui/src/hooks/backend.ts +++ b/packages/auditor-backoffice-ui/src/hooks/backend.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -17,461 +17,245 @@ /** * * @author Sebastian Javier Marchano (sebasjm) + * @author Nic Eigel */ -import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util"; import { - ErrorType, - HttpError, - HttpResponse, - HttpResponseOk, - RequestError, - RequestOptions, - useApiContext, + HttpResponse, + HttpResponseOk, + RequestError, + RequestOptions, + useApiContext, } from "@gnu-taler/web-util/browser"; -import { useCallback, useEffect, useState } from "preact/hooks"; -import { useSWRConfig } from "swr"; +import {useCallback, useEffect, useState} from "preact/hooks"; +import {useSWRConfig} from "swr"; import { useBackendContext } from "../context/backend.js"; -import { useInstanceContext } from "../context/instance.js"; -import { AccessToken, LoginToken, MerchantBackend, Timestamp } from "../declaration.js"; - +import { AuditorBackend } from "../declaration.js"; export function useMatchMutate(): ( - re?: RegExp, - value?: unknown, + re?: RegExp, + value?: unknown, ) => Promise<any> { - const { cache, mutate } = useSWRConfig(); + const {cache, mutate} = useSWRConfig(); - if (!(cache instanceof Map)) { - throw new Error( - "matchMutate requires the cache provider to be a Map instance", - ); - } - - return function matchRegexMutate(re?: RegExp) { - return mutate((key) => { - // evict if no key or regex === all - if (!key || !re) return 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, - }); - }; + if (!(cache instanceof Map)) { + throw new Error( + "matchMutate requires the cache provider to be a Map instance", + ); + } + + return function matchRegexMutate(re?: RegExp) { + return mutate((key) => { + // evict if no key or regex === all + if (!key || !re) return 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, + }); + }; } -export function useBackendInstancesTestForAdmin(): HttpResponse< - MerchantBackend.Instances.InstancesResponse, - MerchantBackend.ErrorDetail +const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000; +const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000; + +export function useBackendConfig(): HttpResponse< + AuditorBackend.VersionResponse | undefined, + RequestError<AuditorBackend.ErrorDetail> > { - const { request } = useBackendBaseRequest(); + const {request} = useBackendBaseRequest(); - type Type = MerchantBackend.Instances.InstancesResponse; + type Type = AuditorBackend.VersionResponse; + type State = { data: HttpResponse<Type, RequestError<AuditorBackend.ErrorDetail>>, timer: number } + const [result, setResult] = useState<State>({data: {loading: true}, timer: 0}); - const [result, setResult] = useState< - HttpResponse<Type, MerchantBackend.ErrorDetail> - >({ loading: true }); + useEffect(() => { + if (result.timer) { + clearTimeout(result.timer); + } - useEffect(() => { - request<Type>(`/management/instances`) - .then((data) => setResult(data)) - .catch((error: RequestError<MerchantBackend.ErrorDetail>) => - setResult(error.cause), - ); - }, [request]); + function tryConfig(): void { + request<Type>(`/config`) + .then((data) => { + const timer: any = setTimeout(() => { + tryConfig(); + }, CHECK_CONFIG_INTERVAL_OK); + setResult({data, timer}); + }) + .catch((error) => { + const timer: any = setTimeout(() => { + tryConfig(); + }, CHECK_CONFIG_INTERVAL_FAIL); + const data = error.cause; + setResult({data, timer}); + }); + } - return result; -} + tryConfig(); + }, [request]); -const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000; -const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000; + return result.data; +} -export function useBackendConfig(): HttpResponse< - MerchantBackend.VersionResponse | undefined, - RequestError<MerchantBackend.ErrorDetail> +export function useBackendToken(): HttpResponse< + AuditorBackend.VersionResponse, + RequestError<AuditorBackend.ErrorDetail> > { - const { request } = useBackendBaseRequest(); + const {request} = useBackendBaseRequest(); - type Type = MerchantBackend.VersionResponse; - type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number } - const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 }); + type Type = AuditorBackend.VersionResponse; + type State = { data: HttpResponse<Type, RequestError<AuditorBackend.ErrorDetail>>, timer: number } + const [result, setResult] = useState<State>({data: {loading: true}, timer: 0}); - useEffect(() => { - if (result.timer) { - clearTimeout(result.timer) - } - function tryConfig(): void { - request<Type>(`/config`) - .then((data) => { - const timer: any = setTimeout(() => { - tryConfig() - }, CHECK_CONFIG_INTERVAL_OK) - setResult({ data, timer }) - }) - .catch((error) => { - const timer: any = setTimeout(() => { - tryConfig() - }, CHECK_CONFIG_INTERVAL_FAIL) - const data = error.cause - setResult({ data, timer }) - }); - } - tryConfig() - }, [request]); + useEffect(() => { + if (result.timer) { + clearTimeout(result.timer); + } + + function tryToken(): void { + request<Type>(`/monitoring/balances`) + .then((data) => { + const timer: any = setTimeout(() => { + tryToken(); + }, CHECK_CONFIG_INTERVAL_OK); + setResult({data, timer}); + }) + .catch((error) => { + const timer: any = setTimeout(() => { + tryToken(); + }, CHECK_CONFIG_INTERVAL_FAIL); + const data = error.cause; + setResult({data, timer}); + }); + } + + tryToken(); + }, [request]); - return result.data; + return result.data; } interface useBackendInstanceRequestType { - request: <T>( - endpoint: string, - options?: RequestOptions, - ) => Promise<HttpResponseOk<T>>; - fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; - reserveDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; - rewardsDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; - multiFetcher: <T>(params: [url: string[]]) => Promise<HttpResponseOk<T>[]>; - orderFetcher: <T>( - params: [endpoint: string, - paid?: YesOrNo, - refunded?: YesOrNo, - wired?: YesOrNo, - searchDate?: Date, - delta?: number,] - ) => Promise<HttpResponseOk<T>>; - transferFetcher: <T>( - params: [endpoint: string, - payto_uri?: string, - verified?: string, - position?: string, - delta?: number,] - ) => Promise<HttpResponseOk<T>>; - templateFetcher: <T>( - params: [endpoint: string, - position?: string, - delta?: number] - ) => Promise<HttpResponseOk<T>>; - webhookFetcher: <T>( - params: [endpoint: string, - position?: string, - delta?: number] - ) => Promise<HttpResponseOk<T>>; -} -interface useBackendBaseRequestType { - request: <T>( - endpoint: string, - options?: RequestOptions, - ) => Promise<HttpResponseOk<T>>; -} -type YesOrNo = "yes" | "no"; -type LoginResult = { - valid: true; - token: string; - expiration: Timestamp; -} | { - valid: false; - cause: HttpError<{}>; + request: <T>( + endpoint: string, + options?: RequestOptions, + ) => Promise<HttpResponseOk<T>>; + fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; + multiFetcher: <T>(params: string[]) => Promise<HttpResponseOk<T>[]>; + depositConfirmationFetcher: <T>( + params: [ + endpoint: string, + ], + ) => Promise<HttpResponseOk<T>>; } -export function useCredentialsChecker() { - const { request } = useApiContext(); - //check against instance details endpoint - //while merchant backend doesn't have a login endpoint - async function requestNewLoginToken( - baseUrl: string, - token: AccessToken, - ): Promise<LoginResult> { - const data: MerchantBackend.Instances.LoginTokenRequest = { - scope: "write", - duration: { - d_us: "forever" - }, - refreshable: true, - } - try { - const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, { - method: "POST", - token, - data - }); - return { valid: true, token: response.data.token, expiration: response.data.expiration }; - } catch (error) { - if (error instanceof RequestError) { - return { valid: false, cause: error.cause }; - } - - return { - valid: false, cause: { - type: ErrorType.UNEXPECTED, - loading: false, - info: { - hasToken: true, - status: 0, - options: {}, - url: `/private/token`, - payload: {} - }, - exception: error, - message: (error instanceof Error ? error.message : "unpexepected error") - } - }; - } - }; - - async function refreshLoginToken( - baseUrl: string, - token: LoginToken - ): Promise<LoginResult> { - - if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) { - return { - valid: false, cause: { - type: ErrorType.CLIENT, - status: HttpStatusCode.Unauthorized, - message: "login token expired, login again.", - info: { - hasToken: true, - status: 401, - options: {}, - url: `/private/token`, - payload: {} - }, - payload: {} - }, - } - } +interface useBackendBaseRequestType { - return requestNewLoginToken(baseUrl, token.token as AccessToken) - } - return { requestNewLoginToken, refreshLoginToken } + request: <T>( + endpoint: string, + options?: RequestOptions + ) => Promise<HttpResponseOk<T>>; } +type YesOrNo = "yes" | "no"; + /** * * @param root the request is intended to the base URL and no the instance URL * @returns request handler to */ +//TODO: Add token export function useBackendBaseRequest(): useBackendBaseRequestType { - const { url: backend, token: loginToken } = useBackendContext(); - const { request: requestHandler } = useApiContext(); - const token = loginToken?.token; - - const request = useCallback( - function requestImpl<T>( - endpoint: string, - options: RequestOptions = {}, - ): Promise<HttpResponseOk<T>> { - return requestHandler<T>(backend, endpoint, { ...options, token }).then(res => { - return res - }).catch(err => { - throw err - }); - }, - [backend, token], - ); - - return { request }; + const {url: backend} = useBackendContext(); + const {request: requestHandler} = useApiContext(); + //const { token } = useBackendTokenContext(); + const token = "D4CST1Z6AHN3RT03M0T9NSTF2QGHTB5ZD2D3RYZB4HAWG8SX0JEFWBXCKXZHMB7Y3Z7KVFW0B3XPXD5BHCFP8EB0R6CNH2KAWDWVET0"; + + + const request = useCallback( + function requestImpl<T>( + endpoint: string, + //todo: remove + options: RequestOptions = {}, + ): Promise<HttpResponseOk<T>> { + return requestHandler<T>(backend, endpoint, {...options, token}).then(res => { + return res; + }).catch(err => { + throw err; + }); + }, + [backend], + ); + + return {request}; } -export function useBackendInstanceRequest(): useBackendInstanceRequestType { - const { url: rootBackendUrl, token: rootToken } = useBackendContext(); - const { token: instanceToken, id, admin } = useInstanceContext(); - const { request: requestHandler } = useApiContext(); - - const { baseUrl, token: loginToken } = !admin - ? { baseUrl: rootBackendUrl, token: rootToken } - : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken }; - - const token = loginToken?.token; - - const request = useCallback( - function requestImpl<T>( - endpoint: string, - options: RequestOptions = {}, - ): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint, { token, ...options }); - }, - [baseUrl, token], - ); - - const multiFetcher = useCallback( - function multiFetcherImpl<T>( - args: [endpoints: string[]], - ): Promise<HttpResponseOk<T>[]> { - const [endpoints] = args - return Promise.all( - endpoints.map((endpoint) => - requestHandler<T>(baseUrl, endpoint, { token }), - ), - ); - }, - [baseUrl, token], - ); - - const fetcher = useCallback( - function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint, { token }); - }, - [baseUrl, token], - ); - - const orderFetcher = useCallback( - function orderFetcherImpl<T>( - args: [endpoint: string, - paid?: YesOrNo, - refunded?: YesOrNo, - wired?: YesOrNo, - searchDate?: Date, - delta?: number,] - ): Promise<HttpResponseOk<T>> { - const [endpoint, paid, refunded, wired, searchDate, delta] = args - const date_s = - delta && delta < 0 && searchDate - ? Math.floor(searchDate.getTime() / 1000) + 1 - : searchDate !== undefined ? Math.floor(searchDate.getTime() / 1000) : undefined; - const params: any = {}; - if (paid !== undefined) params.paid = paid; - if (delta !== undefined) params.delta = delta; - if (refunded !== undefined) params.refunded = refunded; - if (wired !== undefined) params.wired = wired; - if (date_s !== undefined) params.date_s = date_s; - if (delta === 0) { - //in this case we can already assume the response - //and avoid network - return Promise.resolve({ - ok: true, - data: { orders: [] } as T, - }) - } - return requestHandler<T>(baseUrl, endpoint, { params, token }); - }, - [baseUrl, token], - ); - - const reserveDetailFetcher = useCallback( - function reserveDetailFetcherImpl<T>( - endpoint: string, - ): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint, { - params: { - rewards: "yes", + +export function useBackendRequest(): useBackendInstanceRequestType { + const {url: rootBackendUrl} = useBackendContext(); + // const {id} = useInstanceContext(); + const {request: requestHandler} = useApiContext(); + + //TODO: check + const baseUrl = "http://localhost:8083/"; + const token = "D4CST1Z6AHN3RT03M0T9NSTF2QGHTB5ZD2D3RYZB4HAWG8SX0JEFWBXCKXZHMB7Y3Z7KVFW0B3XPXD5BHCFP8EB0R6CNH2KAWDWVET0"; + + + + + const request = useCallback( + function requestImpl<T>( + endpoint: string, + options: RequestOptions = {}, + ): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint, {...options, token}); }, - token, - }); - }, - [baseUrl, token], - ); - - const rewardsDetailFetcher = useCallback( - function rewardsDetailFetcherImpl<T>( - endpoint: string, - ): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint, { - params: { - pickups: "yes", + [baseUrl], + ); + + const multiFetcher = useCallback( + function multiFetcherImpl<T>( + params: string[], + options: RequestOptions = {}, + ): Promise<HttpResponseOk<T>[]> { + return Promise.all( + params.map((endpoint) => + requestHandler<T>(baseUrl, endpoint, {...options, token}), + ), + ); }, - token, - }); - }, - [baseUrl, token], - ); - - const transferFetcher = useCallback( - function transferFetcherImpl<T>( - args: [endpoint: string, - payto_uri?: string, - verified?: string, - position?: string, - delta?: number,] - ): Promise<HttpResponseOk<T>> { - const [endpoint, payto_uri, verified, position, delta] = args - const params: any = {}; - if (payto_uri !== undefined) params.payto_uri = payto_uri; - if (verified !== undefined) params.verified = verified; - if (delta === 0) { - //in this case we can already assume the response - //and avoid network - return Promise.resolve({ - ok: true, - data: { transfers: [] } as T, - }) - } - if (delta !== undefined) { - params.limit = delta; - } - if (position !== undefined) params.offset = position; - - return requestHandler<T>(baseUrl, endpoint, { params, token }); - }, - [baseUrl, token], - ); - - const templateFetcher = useCallback( - function templateFetcherImpl<T>( - args: [endpoint: string, - position?: string, - delta?: number,] - ): Promise<HttpResponseOk<T>> { - const [endpoint, position, delta] = args - const params: any = {}; - if (delta === 0) { - //in this case we can already assume the response - //and avoid network - return Promise.resolve({ - ok: true, - data: { templates: [] } as T, - }) - } - if (delta !== undefined) { - params.limit = delta; - } - if (position !== undefined) params.offset = position; - - return requestHandler<T>(baseUrl, endpoint, { params, token }); - }, - [baseUrl, token], - ); - - const webhookFetcher = useCallback( - function webhookFetcherImpl<T>( - args: [endpoint: string, - position?: string, - delta?: number,] - ): Promise<HttpResponseOk<T>> { - const [endpoint, position, delta] = args - const params: any = {}; - if (delta === 0) { - //in this case we can already assume the response - //and avoid network - return Promise.resolve({ - ok: true, - data: { webhooks: [] } as T, - }) - } - if (delta !== undefined) { - params.limit = delta; - } - if (position !== undefined) params.offset = position; - - return requestHandler<T>(baseUrl, endpoint, { params, token }); - }, - [baseUrl, token], - ); - - return { - request, - fetcher, - multiFetcher, - orderFetcher, - reserveDetailFetcher, - rewardsDetailFetcher, - transferFetcher, - templateFetcher, - webhookFetcher, - }; -} + [baseUrl], + ); + + const fetcher = useCallback( + function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint, {token}); + }, + [baseUrl], + ); + + const depositConfirmationFetcher = useCallback( + function orderFetcherImpl<T>( + args: [endpoint: string, + ], + ): Promise<HttpResponseOk<T>> { + const [endpoint] = args; + const params: any = {"token": "D4CST1Z6AHN3RT03M0T9NSTF2QGHTB5ZD2D3RYZB4HAWG8SX0JEFWBXCKXZHMB7Y3Z7KVFW0B3XPXD5BHCFP8EB0R6CNH2KAWDWVET0"}; + return requestHandler<T>(baseUrl, endpoint, {params, token}); + }, + [baseUrl], + ); + + + return { + request, + fetcher, + depositConfirmationFetcher, + multiFetcher + }; +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/hooks/bank.ts b/packages/auditor-backoffice-ui/src/hooks/bank.ts deleted file mode 100644 index 03b064646..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/bank.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* - 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 }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/critical.ts b/packages/auditor-backoffice-ui/src/hooks/critical.ts new file mode 100644 index 000000000..6a25d3037 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/critical.ts @@ -0,0 +1,70 @@ +/* + 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 { AuditorBackend, WithId } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; + +const useSWR = _useSWR as unknown as SWRHook; + +type YesOrNo = "yes" | "no"; + +export interface HelperDashboardFilter { + finance?: YesOrNo; + security?: YesOrNo; + operating?: YesOrNo; + detail?: YesOrNo; +} + +export function getCriticalData( + args?: HelperDashboardFilter, + updateFilter?: (d: Date) => void, +): HttpResponse<any, AuditorBackend.ErrorDetail> { + const { multiFetcher } = useBackendRequest(); + const endpoints = [ + "monitoring/fee-time-inconsistency", + "monitoring/emergency", + "monitoring/emergency-by-count", + "monitoring/reserve-balance-insufficient-inconsistency", + ]; + + + const { data: list, error: listError } = useSWR< + HttpResponseOk<any>[], RequestError<AuditorBackend.ErrorDetail> + >(endpoints, multiFetcher, { + refreshInterval: 60, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (listError) return listError.cause; + + if (list) { + return { ok: true, data: [list] }; + } + return { loading: true }; +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts b/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts deleted file mode 100644 index e4ec9a2f2..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts +++ /dev/null @@ -1,161 +0,0 @@ -/*
- This file is part of GNU Taler
- (C) 2021-2024 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,
- RequestError,
-} from "@gnu-taler/web-util/browser";
-import { AuditorBackend, MerchantBackend, WithId } from "../declaration.js";
-import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
-
-// FIX default import https://github.com/microsoft/TypeScript/issues/49189
-import _useSWR, { SWRHook, useSWRConfig } from "swr";
-const useSWR = _useSWR as unknown as SWRHook;
-
-export interface DepositConfirmationAPI {
- getDepositConfirmation: (
- id: string,
- ) => Promise<void>;
- createDepositConfirmation: (
- data: MerchantBackend.Products.ProductAddDetail,
- ) => Promise<void>;
- updateDepositConfirmation: (
- id: string,
- data: MerchantBackend.Products.ProductPatchDetail,
- ) => Promise<void>;
- deleteDepositConfirmation: (id: string) => Promise<void>;
-}
-
-export function useDepositConfirmationAPI(): DepositConfirmationAPI {
- const mutateAll = useMatchMutate();
- const { mutate } = useSWRConfig();
-
- const { request } = useBackendInstanceRequest();
-
- const createDepositConfirmation = async (
- data: MerchantBackend.Products.ProductAddDetail,
- ): Promise<void> => {
- const res = await request(`/private/products`, {
- method: "POST",
- data,
- });
-
- return await mutateAll(/.*\/private\/products.*/);
- };
-
- const updateDepositConfirmation = async (
- productId: string,
- data: MerchantBackend.Products.ProductPatchDetail,
- ): Promise<void> => {
- const r = await request(`/private/products/${productId}`, {
- method: "PATCH",
- data,
- });
-
- return await mutateAll(/.*\/private\/products.*/);
- };
-
- const deleteDepositConfirmation = async (productId: string): Promise<void> => {
- await request(`/private/products/${productId}`, {
- method: "DELETE",
- });
- await mutate([`/private/products`]);
- };
-
- const getDepositConfirmation = async (
- serialId: string,
- ): Promise<void> => {
- await request(`/deposit-confirmation/${serialId}`, {
- method: "GET",
- });
-
- return
- };
-
- return {createDepositConfirmation, updateDepositConfirmation, deleteDepositConfirmation, getDepositConfirmation};
-}
-
-export function useDepositConfirmation(): HttpResponse<
- (AuditorBackend.DepositConfirmation.DepositConfirmationDetail & WithId)[],
- AuditorBackend.ErrorDetail
-> {
- const { fetcher, multiFetcher } = useBackendInstanceRequest();
-
- const { data: list, error: listError } = useSWR<
- HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationList>,
- RequestError<AuditorBackend.ErrorDetail>
- >([`/deposit-confirmation`], fetcher, {
- refreshInterval: 0,
- refreshWhenHidden: false,
- revalidateOnFocus: false,
- revalidateOnReconnect: false,
- refreshWhenOffline: false,
- });
-
- const paths = (list?.data.depositConfirmations || []).map(
- (p) => `/deposit-confirmation/${p.serial_id}`,
- );
- const { data: depositConfirmations, error: depositConfirmationError } = useSWR<
- HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>[],
- RequestError<AuditorBackend.ErrorDetail>
- >([paths], multiFetcher, {
- refreshInterval: 0,
- refreshWhenHidden: false,
- revalidateOnFocus: false,
- revalidateOnReconnect: false,
- refreshWhenOffline: false,
- });
-
- if (listError) return listError.cause;
- if (depositConfirmationError) return depositConfirmationError.cause;
-
- if (depositConfirmations) {
- const dataWithId = depositConfirmations.map((d) => {
- //take the id from the queried url
- return {
- ...d.data,
- id: d.info?.url.replace(/.*\/deposit-confirmation\//, "") || "",
- };
- });
- return { ok: true, data: dataWithId };
- }
- return { loading: true };
-}
-
-export function useDepositConfirmationDetails(
- serialId: string,
-): HttpResponse<
- AuditorBackend.DepositConfirmation.DepositConfirmationDetail,
- AuditorBackend.ErrorDetail
-> {
- const { fetcher } = useBackendInstanceRequest();
-
- const { data, error, isValidating } = useSWR<
- HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>,
- RequestError<AuditorBackend.ErrorDetail>
- >([`/deposit-confirmation/${serialId}`], 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 };
-}
diff --git a/packages/auditor-backoffice-ui/src/hooks/entity.ts b/packages/auditor-backoffice-ui/src/hooks/entity.ts new file mode 100644 index 000000000..ae62da35e --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/entity.ts @@ -0,0 +1,82 @@ +/* + 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, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { AuditorBackend, WithId } from "../declaration.js"; +import { useBackendRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; +import { useEntityContext } from "../context/entity.js"; + +const useSWR = _useSWR as unknown as SWRHook; + +type YesOrNo = "yes" | "no"; + +interface Props { + endpoint: string; + entity: any; +} + +export function getEntityList({ endpoint, entity }: Props): HttpResponse<any, AuditorBackend.ErrorDetail> { + const { fetcher } = useBackendRequest(); + + const { data: list, error: listError } = useSWR< + HttpResponseOk<typeof entity>, + RequestError<AuditorBackend.ErrorDetail> + >([`monitoring/` + endpoint], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (listError) return listError.cause; + + if (list?.data != null) { + return { ok: true, data: [list?.data] }; + } + return { loading: true }; +} +export interface EntityAPI { + updateEntity: ( + id: string + ) => Promise<void>; +} + +export function useEntityAPI(): EntityAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendRequest(); + const { endpoint } = useEntityContext(); + const data = {"suppressed": true}; + + const updateEntity = async ( + id: string, + ): Promise<void> => { + const r = await request(`monitoring/${endpoint}/${id}`, { + method: "PATCH", + data, + }); + + return await mutateAll(/.*\/monitoring.*/); + }; + + return { updateEntity }; +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/hooks/finance.ts b/packages/auditor-backoffice-ui/src/hooks/finance.ts new file mode 100644 index 000000000..97bf2577f --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/finance.ts @@ -0,0 +1,61 @@ +/* + 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, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { AuditorBackend, WithId } from "../declaration.js"; +import { useBackendRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; + +const useSWR = _useSWR as unknown as SWRHook; + + +export function getKeyFiguresData(): HttpResponse<any, AuditorBackend.ErrorDetail> { + const { multiFetcher } = useBackendRequest(); + const endpoints = [ + "monitoring/misattribution-in-inconsistency", + "monitoring/coin-inconsistency", + "monitoring/reserve-in-inconsistency", + "monitoring/bad-sig-losses", + "monitoring/balances", + "monitoring/amount-arithmetic-inconsistency", + "monitoring/wire-format-inconsistency", + "monitoring/wire-out-inconsistency", + "monitoring/reserve-balance-summary-wrong-inconsistency", + + ]; + + const { data: list, error: listError } = useSWR< + HttpResponseOk<any>[], RequestError<AuditorBackend.ErrorDetail> + >(endpoints, multiFetcher, { + refreshInterval: 60, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (listError) return listError.cause; + + if (list) { + return { ok: true, data: [list] }; + } + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/index.ts b/packages/auditor-backoffice-ui/src/hooks/index.ts index 61afbc94a..cf1c57771 100644 --- a/packages/auditor-backoffice-ui/src/hooks/index.ts +++ b/packages/auditor-backoffice-ui/src/hooks/index.ts @@ -14,138 +14,66 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { buildCodecForObject, codecForMap, codecForString, codecForTimestamp } from "@gnu-taler/taler-util"; -import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; -import { StateUpdater, useEffect, useState } from "preact/hooks"; -import { LoginToken } from "../declaration.js"; +import {StateUpdater, useState} from "preact/hooks"; import { ValueOrFunction } from "../utils/types.js"; -import { useMatchMutate } from "./backend.js"; - -const calculateRootPath = () => { - const rootPath = - typeof window !== undefined - ? window.location.origin + window.location.pathname - : "/"; - - /** - * By default, merchant backend serves the html content - * from the /webui root. This should cover most of the - * cases and the rootPath will be the merchant backend - * URL where the instances are - */ - return rootPath.replace("/webui/", ""); -}; - -const loginTokenCodec = buildCodecForObject<LoginToken>() - .property("token", codecForString()) - .property("expiration", codecForTimestamp) - .build("loginToken") -const TOKENS_KEY = buildStorageKey("auditor-token", codecForMap(loginTokenCodec)); - export function useBackendURL( - url?: string, + url?: string, ): [string, StateUpdater<string>] { - const [value, setter] = useSimpleLocalStorage( - "auditor-base-url", - url || calculateRootPath(), - ); - - const checkedSetter = (v: ValueOrFunction<string>) => { - return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, "")); - }; - - return [value!, checkedSetter]; -} + const [value, setter] = useSimpleLocalStorage( + "auditor-base-url", + url || calculateRootPath(), + ); -export function useBackendDefaultToken( -): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { - const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) + const checkedSetter = (v: ValueOrFunction<string>) => { + return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, "")); + }; - const tokenOfDefaultInstance = tokenMap["default"] - const clearCache = useMatchMutate() - useEffect(() => { - clearCache() - }, [tokenOfDefaultInstance]) - - function updateToken( - value: (LoginToken | undefined) - ): void { - if (value === undefined) { - reset() - } else { - const res = { ...tokenMap, "default": value } - setToken(res) - } - } - return [tokenMap["default"], updateToken]; -} - -export function useBackendInstanceToken( - id: string, -): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { - const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) - const [defaultToken, defaultSetToken] = useBackendDefaultToken(); - - // instance named 'default' use the default token - if (id === "default") { - return [defaultToken, defaultSetToken]; - } - function updateToken( - value: (LoginToken | undefined) - ): void { - if (value === undefined) { - reset() - } else { - const res = { ...tokenMap, [id]: value } - setToken(res) - } - } - - return [tokenMap[id], updateToken]; + return [value!, checkedSetter]; } -export function useLang(initial?: string): [string, StateUpdater<string>] { - const browserLang = - typeof window !== "undefined" - ? navigator.language || (navigator as any).userLanguage - : undefined; - const defaultLang = (browserLang || initial || "en").substring(0, 2); - return useSimpleLocalStorage("lang-preference", defaultLang) as [string, StateUpdater<string>]; -} +const calculateRootPath = () => { + const rootPath = + typeof window !== undefined + ? window.location.origin + window.location.pathname + : "/"; + + /** + * By default, auditor backend serves the html content + * from the /webui root. This should cover most of the + * cases and the rootPath will be the auditor backend + * URL where the instances are + */ + return rootPath.replace("/webui/", ""); +}; export function useSimpleLocalStorage( - key: string, - initialValue?: string, + key: string, + initialValue?: string, ): [string | undefined, StateUpdater<string | undefined>] { - const [storedValue, setStoredValue] = useState<string | undefined>( - (): string | undefined => { - return typeof window !== "undefined" - ? window.localStorage.getItem(key) || initialValue - : initialValue; - }, - ); - - const setValue = ( - value?: string | ((val?: string) => string | undefined), - ) => { - setStoredValue((p) => { - const toStore = value instanceof Function ? value(p) : value; - if (typeof window !== "undefined") { - if (!toStore) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, toStore); - } - } - return toStore; - }); - }; - - return [storedValue, setValue]; -} + const [storedValue, setStoredValue] = useState<string | undefined>( + (): string | undefined => { + return typeof window !== "undefined" + ? window.localStorage.getItem(key) || initialValue + : initialValue; + }, + ); + + const setValue = ( + value?: string | ((val?: string) => string | undefined), + ) => { + setStoredValue((p) => { + const toStore = value instanceof Function ? value(p) : value; + if (typeof window !== "undefined") { + if (!toStore) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, toStore); + } + } + return toStore; + }); + }; + + return [storedValue, setValue]; +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/hooks/instance.test.ts b/packages/auditor-backoffice-ui/src/hooks/instance.test.ts deleted file mode 100644 index ee1576764..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/instance.test.ts +++ /dev/null @@ -1,741 +0,0 @@ -/* - 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 * as tests from "@gnu-taler/web-util/testing"; -import { expect } from "chai"; -import { AccessToken, MerchantBackend } from "../declaration.js"; -import { - useAdminAPI, - useBackendInstances, - useInstanceAPI, - useInstanceDetails, - useManagementAPI, -} from "./instance.js"; -import { ApiMockEnvironment } from "./testing.js"; -import { - API_CREATE_INSTANCE, - API_DELETE_INSTANCE, - API_GET_CURRENT_INSTANCE, - API_LIST_INSTANCES, - API_NEW_LOGIN, - API_UPDATE_CURRENT_INSTANCE, - API_UPDATE_CURRENT_INSTANCE_AUTH, - API_UPDATE_INSTANCE_BY_ID, -} from "./urls.js"; - -describe("instance api interaction with details", () => { - it("should evict cache when updating an instance", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { - response: { - name: "instance_name", - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useInstanceAPI(); - const query = useInstanceDetails(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - name: "instance_name", - }); - env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE, { - request: { - name: "other_name", - } as MerchantBackend.Instances.InstanceReconfigurationMessage, - }); - env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { - response: { - name: "other_name", - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - api.updateInstance({ - name: "other_name", - } as MerchantBackend.Instances.InstanceReconfigurationMessage); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - name: "other_name", - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when setting the instance's token", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { - response: { - name: "instance_name", - auth: { - method: "token", - // token: "not-secret", - }, - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useInstanceAPI(); - const query = useInstanceDetails(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - name: "instance_name", - auth: { - method: "token", - }, - }); - env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, { - request: { - method: "token", - token: "secret", - } as MerchantBackend.Instances.InstanceAuthConfigurationMessage, - }); - env.addRequestExpectation(API_NEW_LOGIN, { - auth: "secret", - request: { - scope: "write", - duration: { - "d_us": "forever", - }, - refreshable: true, - }, - }); - env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { - response: { - name: "instance_name", - auth: { - method: "token", - // token: "secret", - }, - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - api.setNewAccessToken(undefined, "secret" as AccessToken); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - name: "instance_name", - auth: { - method: "token", - // token: "secret", - }, - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when clearing the instance's token", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { - response: { - name: "instance_name", - auth: { - method: "token", - // token: "not-secret", - }, - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useInstanceAPI(); - const query = useInstanceDetails(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - name: "instance_name", - auth: { - method: "token", - // token: "not-secret", - }, - }); - env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, { - request: { - method: "external", - } as MerchantBackend.Instances.InstanceAuthConfigurationMessage, - }); - env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { - response: { - name: "instance_name", - auth: { - method: "external", - }, - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - - api.clearAccessToken(undefined); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - name: "instance_name", - auth: { - method: "external", - }, - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - // const { result, waitForNextUpdate } = renderHook( - // () => { - // const api = useInstanceAPI(); - // const query = useInstanceDetails(); - - // return { query, api }; - // }, - // { wrapper: TestingContext } - // ); - - // expect(result.current).not.undefined; - // if (!result.current) { - // return; - // } - // expect(result.current.query.loading).true; - - // await waitForNextUpdate({ timeout: 1 }); - - // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - - // expect(result.current.query.loading).false; - - // expect(result.current?.query.ok).true; - // if (!result.current?.query.ok) return; - - // expect(result.current.query.data).equals({ - // name: 'instance_name', - // auth: { - // method: 'token', - // token: 'not-secret', - // } - // }); - - // act(async () => { - // await result.current?.api.clearToken(); - // }); - - // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - - // expect(result.current.query.loading).false; - - // await waitForNextUpdate({ timeout: 1 }); - - // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - - // expect(result.current.query.loading).false; - // expect(result.current.query.ok).true; - - // expect(result.current.query.data).equals({ - // name: 'instance_name', - // auth: { - // method: 'external', - // } - // }); - }); -}); - -describe("instance admin api interaction with listing", () => { - it("should evict cache when creating a new instance", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_INSTANCES, { - response: { - instances: [ - { - name: "instance_name", - } as MerchantBackend.Instances.Instance, - ], - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useAdminAPI(); - const query = useBackendInstances(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - instances: [ - { - name: "instance_name", - }, - ], - }); - - env.addRequestExpectation(API_CREATE_INSTANCE, { - request: { - name: "other_name", - } as MerchantBackend.Instances.InstanceConfigurationMessage, - }); - env.addRequestExpectation(API_LIST_INSTANCES, { - response: { - instances: [ - { - name: "instance_name", - } as MerchantBackend.Instances.Instance, - { - name: "other_name", - } as MerchantBackend.Instances.Instance, - ], - }, - }); - - api.createInstance({ - name: "other_name", - } as MerchantBackend.Instances.InstanceConfigurationMessage); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - instances: [ - { - name: "instance_name", - }, - { - name: "other_name", - }, - ], - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when deleting an instance", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_INSTANCES, { - response: { - instances: [ - { - id: "default", - name: "instance_name", - } as MerchantBackend.Instances.Instance, - { - id: "the_id", - name: "second_instance", - } as MerchantBackend.Instances.Instance, - ], - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useAdminAPI(); - const query = useBackendInstances(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - instances: [ - { - id: "default", - name: "instance_name", - }, - { - id: "the_id", - name: "second_instance", - }, - ], - }); - - env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), {}); - env.addRequestExpectation(API_LIST_INSTANCES, { - response: { - instances: [ - { - id: "default", - name: "instance_name", - } as MerchantBackend.Instances.Instance, - ], - }, - }); - - api.deleteInstance("the_id"); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - instances: [ - { - id: "default", - name: "instance_name", - }, - ], - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - - // const { result, waitForNextUpdate } = renderHook( - // () => { - // const api = useAdminAPI(); - // const query = useBackendInstances(); - - // return { query, api }; - // }, - // { wrapper: TestingContext } - // ); - - // expect(result.current).not.undefined; - // if (!result.current) { - // return; - // } - // expect(result.current.query.loading).true; - - // await waitForNextUpdate({ timeout: 1 }); - - // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - - // expect(result.current.query.loading).false; - - // expect(result.current?.query.ok).true; - // if (!result.current?.query.ok) return; - - // expect(result.current.query.data).equals({ - // instances: [{ - // id: 'default', - // name: 'instance_name' - // }, { - // id: 'the_id', - // name: 'second_instance' - // }] - // }); - - // env.addRequestExpectation(API_DELETE_INSTANCE('the_id'), {}); - - // act(async () => { - // await result.current?.api.deleteInstance('the_id'); - // }); - - // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - - // env.addRequestExpectation(API_LIST_INSTANCES, { - // response: { - // instances: [{ - // id: 'default', - // name: 'instance_name' - // } as MerchantBackend.Instances.Instance] - // }, - // }); - - // expect(result.current.query.loading).false; - - // await waitForNextUpdate({ timeout: 1 }); - - // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - - // expect(result.current.query.loading).false; - // expect(result.current.query.ok).true; - - // expect(result.current.query.data).equals({ - // instances: [{ - // id: 'default', - // name: 'instance_name' - // }] - // }); - }); - - it("should evict cache when deleting (purge) an instance", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_INSTANCES, { - response: { - instances: [ - { - id: "default", - name: "instance_name", - } as MerchantBackend.Instances.Instance, - { - id: "the_id", - name: "second_instance", - } as MerchantBackend.Instances.Instance, - ], - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useAdminAPI(); - const query = useBackendInstances(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - instances: [ - { - id: "default", - name: "instance_name", - }, - { - id: "the_id", - name: "second_instance", - }, - ], - }); - - env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), { - qparam: { - purge: "YES", - }, - }); - env.addRequestExpectation(API_LIST_INSTANCES, { - response: { - instances: [ - { - id: "default", - name: "instance_name", - } as MerchantBackend.Instances.Instance, - ], - }, - }); - - api.purgeInstance("the_id"); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - instances: [ - { - id: "default", - name: "instance_name", - }, - ], - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); - -describe("instance management api interaction with listing", () => { - it("should evict cache when updating an instance", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_INSTANCES, { - response: { - instances: [ - { - id: "managed", - name: "instance_name", - } as MerchantBackend.Instances.Instance, - ], - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useManagementAPI("managed"); - const query = useBackendInstances(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - instances: [ - { - id: "managed", - name: "instance_name", - }, - ], - }); - - env.addRequestExpectation(API_UPDATE_INSTANCE_BY_ID("managed"), { - request: { - name: "other_name", - } as MerchantBackend.Instances.InstanceReconfigurationMessage, - }); - env.addRequestExpectation(API_LIST_INSTANCES, { - response: { - instances: [ - { - id: "managed", - name: "other_name", - } as MerchantBackend.Instances.Instance, - ], - }, - }); - - api.updateInstance({ - name: "other_name", - } as MerchantBackend.Instances.InstanceConfigurationMessage); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - instances: [ - { - id: "managed", - name: "other_name", - }, - ], - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); diff --git a/packages/auditor-backoffice-ui/src/hooks/instance.ts b/packages/auditor-backoffice-ui/src/hooks/instance.ts deleted file mode 100644 index 0677191db..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/instance.ts +++ /dev/null @@ -1,313 +0,0 @@ -/* - 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, - RequestError, -} from "@gnu-taler/web-util/browser"; -import { useBackendContext } from "../context/backend.js"; -import { AccessToken, MerchantBackend } from "../declaration.js"; -import { - useBackendBaseRequest, - useBackendInstanceRequest, - useCredentialsChecker, - useMatchMutate, -} from "./backend.js"; - -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook, useSWRConfig } from "swr"; -const useSWR = _useSWR as unknown as SWRHook; - -interface InstanceAPI { - updateInstance: ( - data: MerchantBackend.Instances.InstanceReconfigurationMessage, - ) => Promise<void>; - deleteInstance: () => Promise<void>; - clearAccessToken: (currentToken: AccessToken | undefined) => Promise<void>; - setNewAccessToken: (currentToken: AccessToken | undefined, token: AccessToken) => Promise<void>; -} - -export function useAdminAPI(): AdminAPI { - const { request } = useBackendBaseRequest(); - const mutateAll = useMatchMutate(); - - const createInstance = async ( - instance: MerchantBackend.Instances.InstanceConfigurationMessage, - ): Promise<void> => { - await request(`/management/instances`, { - method: "POST", - data: instance, - }); - - mutateAll(/\/management\/instances/); - }; - - const deleteInstance = async (id: string): Promise<void> => { - await request(`/management/instances/${id}`, { - method: "DELETE", - }); - - mutateAll(/\/management\/instances/); - }; - - const purgeInstance = async (id: string): Promise<void> => { - await request(`/management/instances/${id}`, { - method: "DELETE", - params: { - purge: "YES", - }, - }); - - mutateAll(/\/management\/instances/); - }; - - return { createInstance, deleteInstance, purgeInstance }; -} - -export interface AdminAPI { - createInstance: ( - data: MerchantBackend.Instances.InstanceConfigurationMessage, - ) => Promise<void>; - deleteInstance: (id: string) => Promise<void>; - purgeInstance: (id: string) => Promise<void>; -} - -export function useManagementAPI(instanceId: string): InstanceAPI { - const mutateAll = useMatchMutate(); - const { url: backendURL } = useBackendContext() - const { updateToken } = useBackendContext(); - const { request } = useBackendBaseRequest(); - const { requestNewLoginToken } = useCredentialsChecker() - - const updateInstance = async ( - instance: MerchantBackend.Instances.InstanceReconfigurationMessage, - ): Promise<void> => { - await request(`/management/instances/${instanceId}`, { - method: "PATCH", - data: instance, - }); - - mutateAll(/\/management\/instances/); - }; - - const deleteInstance = async (): Promise<void> => { - await request(`/management/instances/${instanceId}`, { - method: "DELETE", - }); - - mutateAll(/\/management\/instances/); - }; - - const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => { - await request(`/management/instances/${instanceId}/auth`, { - method: "POST", - token: currentToken, - data: { method: "external" }, - }); - - mutateAll(/\/management\/instances/); - }; - - const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => { - await request(`/management/instances/${instanceId}/auth`, { - method: "POST", - token: currentToken, - data: { method: "token", token: newToken }, - }); - - const resp = await requestNewLoginToken(backendURL, newToken) - if (resp.valid) { - const { token, expiration } = resp - updateToken({ token, expiration }); - } else { - updateToken(undefined) - } - - mutateAll(/\/management\/instances/); - }; - - return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken }; -} - -export function useInstanceAPI(): InstanceAPI { - const { mutate } = useSWRConfig(); - const { url: backendURL, updateToken } = useBackendContext() - - const { - token: adminToken, - } = useBackendContext(); - const { request } = useBackendInstanceRequest(); - const { requestNewLoginToken } = useCredentialsChecker() - - const updateInstance = async ( - instance: MerchantBackend.Instances.InstanceReconfigurationMessage, - ): Promise<void> => { - await request(`/private/`, { - method: "PATCH", - data: instance, - }); - - if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); - mutate([`/private/`], null); - }; - - const deleteInstance = async (): Promise<void> => { - await request(`/private/`, { - method: "DELETE", - // token: adminToken, - }); - - if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); - mutate([`/private/`], null); - }; - - const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => { - await request(`/private/auth`, { - method: "POST", - token: currentToken, - data: { method: "external" }, - }); - - mutate([`/private/`], null); - }; - - const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => { - await request(`/private/auth`, { - method: "POST", - token: currentToken, - data: { method: "token", token: newToken }, - }); - - const resp = await requestNewLoginToken(backendURL, newToken) - if (resp.valid) { - const { token, expiration } = resp - updateToken({ token, expiration }); - } else { - updateToken(undefined) - } - - mutate([`/private/`], null); - }; - - return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken }; -} - -export function useInstanceDetails(): HttpResponse< - MerchantBackend.Instances.QueryInstancesResponse, - MerchantBackend.ErrorDetail -> { - const { fetcher } = useBackendInstanceRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/`], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - revalidateIfStale: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - }); - - if (isValidating) return { loading: true, data: data?.data }; - if (data) return data; - if (error) return error.cause; - return { loading: true }; -} - -type KYCStatus = - | { type: "ok" } - | { type: "redirect"; status: MerchantBackend.KYC.AccountKycRedirects }; - -export function useInstanceKYCDetails(): HttpResponse< - KYCStatus, - MerchantBackend.ErrorDetail -> { - const { fetcher } = useBackendInstanceRequest(); - - const { data, error } = useSWR< - HttpResponseOk<MerchantBackend.KYC.AccountKycRedirects>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/kyc`], fetcher, { - refreshInterval: 60 * 1000, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnMount: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - }); - - if (data) { - if (data.info?.status === 202) - return { ok: true, data: { type: "redirect", status: data.data } }; - return { ok: true, data: { type: "ok" } }; - } - if (error) return error.cause; - return { loading: true }; -} - -export function useManagedInstanceDetails( - instanceId: string, -): HttpResponse< - MerchantBackend.Instances.QueryInstancesResponse, - MerchantBackend.ErrorDetail -> { - const { request } = useBackendBaseRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>, - RequestError<MerchantBackend.ErrorDetail> - >([`/management/instances/${instanceId}`], request, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - }); - - if (isValidating) return { loading: true, data: data?.data }; - if (data) return data; - if (error) return error.cause; - return { loading: true }; -} - -export function useBackendInstances(): HttpResponse< - MerchantBackend.Instances.InstancesResponse, - MerchantBackend.ErrorDetail -> { - const { request } = useBackendBaseRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Instances.InstancesResponse>, - RequestError<MerchantBackend.ErrorDetail> - >(["/management/instances"], request); - - if (isValidating) return { loading: true, data: data?.data }; - if (data) return data; - if (error) return error.cause; - return { loading: true }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/listener.ts b/packages/auditor-backoffice-ui/src/hooks/listener.ts deleted file mode 100644 index d101f7bb8..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/listener.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - 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 { useState } from "preact/hooks"; - -/** - * This component is used when a component wants one child to have a trigger for - * an action (a button) and other child have the action implemented (like - * gathering information with a form). The difference with other approaches is - * that in this case the parent component is not holding the state. - * - * It will return a subscriber and activator. - * - * The activator may be undefined, if it is undefined it is indicating that the - * subscriber is not ready to be called. - * - * The subscriber will receive a function (the listener) that will be call when the - * activator runs. The listener must return the collected information. - * - * As a result, when the activator is triggered by a child component, the - * @action function is called receives the information from the listener defined by other - * child component - * - * @param action from <T> to <R> - * @returns activator and subscriber, undefined activator means that there is not subscriber - */ - -export function useListener<T, R = any>( - action: (r: T) => Promise<R>, -): [undefined | (() => Promise<R>), (listener?: () => T) => void] { - type RunnerHandler = { toBeRan?: () => Promise<R> }; - const [state, setState] = useState<RunnerHandler>({}); - - /** - * subscriber will receive a method that will be call when the activator runs - * - * @param listener function to be run when the activator runs - */ - const subscriber = (listener?: () => T) => { - if (listener) { - setState({ - toBeRan: () => { - const whatWeGetFromTheListener = listener(); - return action(whatWeGetFromTheListener); - }, - }); - } else { - setState({ - toBeRan: undefined, - }); - } - }; - - /** - * activator will call runner if there is someone subscribed - */ - const activator = state.toBeRan - ? async () => { - if (state.toBeRan) { - return state.toBeRan(); - } - return Promise.reject(); - } - : undefined; - - return [activator, subscriber]; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/notifications.ts b/packages/auditor-backoffice-ui/src/hooks/notifications.ts deleted file mode 100644 index 133ddd80b..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/notifications.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - 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 { useState } from "preact/hooks"; -import { Notification } from "../utils/types.js"; - -interface Result { - notifications: Notification[]; - pushNotification: (n: Notification) => void; - removeNotification: (n: Notification) => void; -} - -type NotificationWithDate = Notification & { since: Date }; - -export function useNotifications( - initial: Notification[] = [], - timeout = 3000, -): Result { - const [notifications, setNotifications] = useState<NotificationWithDate[]>( - initial.map((i) => ({ ...i, since: new Date() })), - ); - - const pushNotification = (n: Notification): void => { - const entry = { ...n, since: new Date() }; - setNotifications((ns) => [...ns, entry]); - if (n.type !== "ERROR") - setTimeout(() => { - setNotifications((ns) => ns.filter((x) => x.since !== entry.since)); - }, timeout); - }; - - const removeNotification = (notif: Notification) => { - setNotifications((ns: NotificationWithDate[]) => - ns.filter((n) => n !== notif), - ); - }; - return { notifications, pushNotification, removeNotification }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/operational.ts b/packages/auditor-backoffice-ui/src/hooks/operational.ts new file mode 100644 index 000000000..89524f24e --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/operational.ts @@ -0,0 +1,83 @@ +/* + 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, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { AuditorBackend, WithId } from "../declaration.js"; +import { useBackendRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; + +const useSWR = _useSWR as unknown as SWRHook; + +type YesOrNo = "yes" | "no"; + +export interface HelperDashboardFilter { + finance?: YesOrNo; + security?: YesOrNo; + operating?: YesOrNo; + detail?: YesOrNo; +} + +export function getOperationData( + args?: HelperDashboardFilter, + updateFilter?: (d: Date) => void, +): HttpResponse<any, AuditorBackend.ErrorDetail> { + const { multiFetcher } = useBackendRequest(); + const endpoints = [ + "monitoring/row-inconsistency", + "monitoring/purse-not-closed-inconsistencies", + "monitoring/reserve-not-closed-inconsistency", + "monitoring/denominations-without-sigs", + "monitoring/deposit-confirmation", + "monitoring/denomination-key-validity-withdraw-inconsistency", + "monitoring/refreshes-hanging", + //TODO fix endpoint + // "monitoring/closure-lags", + // "monitoring/row-minor-inconsistencies", + // "monitoring/historic-denomination-revenue", + // "monitoring/denomination-pending", + "monitoring/historic-reserve-summary", + + ]; + + + const { data: list, error: listError } = useSWR< + HttpResponseOk<any>[], RequestError<AuditorBackend.ErrorDetail> + >(endpoints, multiFetcher, { + refreshInterval: 60, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (listError) return listError.cause; + + if (list) { + return { ok: true, data: [list] }; + } + return { loading: true }; +} + +export interface EntityAPI { + updateEntity: ( + id: string, + ) => Promise<void>; +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/hooks/order.test.ts b/packages/auditor-backoffice-ui/src/hooks/order.test.ts deleted file mode 100644 index c243309a8..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/order.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -/* - 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 * as tests from "@gnu-taler/web-util/testing"; -import { expect } from "chai"; -import { MerchantBackend } from "../declaration.js"; -import { useInstanceOrders, useOrderAPI, useOrderDetails } from "./order.js"; -import { ApiMockEnvironment } from "./testing.js"; -import { - API_CREATE_ORDER, - API_DELETE_ORDER, - API_FORGET_ORDER_BY_ID, - API_GET_ORDER_BY_ID, - API_LIST_ORDERS, - API_REFUND_ORDER_BY_ID, -} from "./urls.js"; - -describe("order api interaction with listing", () => { - it("should evict cache when creating an order", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -20, paid: "yes" }, - response: { - orders: [{ order_id: "1" }, { order_id: "2" } as MerchantBackend.Orders.OrderHistoryEntry], - }, - }); - - const newDate = (d: Date) => { - //console.log("new date", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useInstanceOrders({ paid: "yes" }, newDate); - const api = useOrderAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [{ order_id: "1" }, { order_id: "2" }], - }); - - env.addRequestExpectation(API_CREATE_ORDER, { - request: { - order: { amount: "ARS:12", summary: "pay me" }, - }, - response: { order_id: "3" }, - }); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -20, paid: "yes" }, - response: { - orders: [{ order_id: "1" }, { order_id: "2" } as any, { order_id: "3" } as any], - }, - }); - - api.createOrder({ - order: { amount: "ARS:12", summary: "pay me" }, - } as any); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [{ order_id: "1" }, { order_id: "2" }, { order_id: "3" }], - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when doing a refund", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -20, paid: "yes" }, - response: { orders: [{ - order_id: "1", - amount: "EUR:12", - refundable: true, - } as MerchantBackend.Orders.OrderHistoryEntry] }, - }); - - const newDate = (d: Date) => { - //console.log("new date", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useInstanceOrders({ paid: "yes" }, newDate); - const api = useOrderAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [ - { - order_id: "1", - amount: "EUR:12", - refundable: true, - }, - ], - }); - env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), { - request: { - reason: "double pay", - refund: "EUR:1", - }, - }); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -20, paid: "yes" }, - response: { orders: [ - { order_id: "1", amount: "EUR:12", refundable: false } as any, - ] }, - }); - - api.refundOrder("1", { - reason: "double pay", - refund: "EUR:1", - }); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [ - { - order_id: "1", - amount: "EUR:12", - refundable: false, - }, - ], - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when deleting an order", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -20, paid: "yes" }, - response: { - orders: [{ order_id: "1" }, { order_id: "2" } as MerchantBackend.Orders.OrderHistoryEntry], - }, - }); - - const newDate = (d: Date) => { - //console.log("new date", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useInstanceOrders({ paid: "yes" }, newDate); - const api = useOrderAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [{ order_id: "1" }, { order_id: "2" }], - }); - - env.addRequestExpectation(API_DELETE_ORDER("1"), {}); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -20, paid: "yes" }, - response: { - orders: [{ order_id: "2" } as any], - }, - }); - - api.deleteOrder("1"); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [{ order_id: "2" }], - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); - -describe("order api interaction with details", () => { - it("should evict cache when doing a refund", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), { - // qparam: { delta: 0, paid: "yes" }, - response: { - summary: "description", - refund_amount: "EUR:0", - } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse, - }); - - const newDate = (d: Date) => { - //console.log("new date", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useOrderDetails("1"); - const api = useOrderAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - summary: "description", - refund_amount: "EUR:0", - }); - env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), { - request: { - reason: "double pay", - refund: "EUR:1", - }, - }); - - env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), { - response: { - summary: "description", - refund_amount: "EUR:1", - } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse, - }); - - api.refundOrder("1", { - reason: "double pay", - refund: "EUR:1", - }); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - summary: "description", - refund_amount: "EUR:1", - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when doing a forget", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), { - // qparam: { delta: 0, paid: "yes" }, - response: { - summary: "description", - refund_amount: "EUR:0", - } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse, - }); - - const newDate = (d: Date) => { - //console.log("new date", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useOrderDetails("1"); - const api = useOrderAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - summary: "description", - refund_amount: "EUR:0", - }); - env.addRequestExpectation(API_FORGET_ORDER_BY_ID("1"), { - request: { - fields: ["$.summary"], - }, - }); - - env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), { - response: { - summary: undefined, - } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse, - }); - - api.forgetOrder("1", { - fields: ["$.summary"], - }); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - summary: undefined, - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); - -describe("order listing pagination", () => { - it("should not load more if has reach the end", async () => { - const env = new ApiMockEnvironment(); - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: 20, wired: "yes", date_s: 12 }, - response: { - orders: [{ order_id: "1" } as any], - }, - }); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -20, wired: "yes", date_s: 13 }, - response: { - orders: [{ order_id: "2" } as any], - }, - }); - - const newDate = (d: Date) => { - //console.log("new date", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const date = new Date(12000); - const query = useInstanceOrders({ wired: "yes", date }, newDate); - const api = useOrderAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [{ order_id: "1" }, { order_id: "2" }], - }); - expect(query.isReachingEnd).true; - expect(query.isReachingStart).true; - - // should not trigger new state update or query - query.loadMore(); - query.loadMorePrev(); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should load more if result brings more that PAGE_SIZE", async () => { - const env = new ApiMockEnvironment(); - - const ordersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({ - order_id: String(i), - })); - const ordersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({ - order_id: String(i + 20), - })); - const ordersFrom20to0 = [...ordersFrom0to20].reverse(); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: 20, wired: "yes", date_s: 12 }, - response: { - orders: ordersFrom0to20, - }, - }); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -20, wired: "yes", date_s: 13 }, - response: { - orders: ordersFrom20to40, - }, - }); - - const newDate = (d: Date) => { - //console.log("new date", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const date = new Date(12000); - const query = useInstanceOrders({ wired: "yes", date }, newDate); - const api = useOrderAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [...ordersFrom20to0, ...ordersFrom20to40], - }); - expect(query.isReachingEnd).false; - expect(query.isReachingStart).false; - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: -40, wired: "yes", date_s: 13 }, - response: { - orders: [...ordersFrom20to40, { order_id: "41" }], - }, - }); - - query.loadMore(); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).true; - }, - ({ query, api }) => { - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [ - ...ordersFrom20to0, - ...ordersFrom20to40, - { order_id: "41" }, - ], - }); - - env.addRequestExpectation(API_LIST_ORDERS, { - qparam: { delta: 40, wired: "yes", date_s: 12 }, - response: { - orders: [...ordersFrom0to20, { order_id: "-1" }], - }, - }); - - query.loadMorePrev(); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).true; - }, - ({ query, api }) => { - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - orders: [ - { order_id: "-1" }, - ...ordersFrom20to0, - ...ordersFrom20to40, - { order_id: "41" }, - ], - }); - }, - ], - env.buildTestingContext(), - ); - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); diff --git a/packages/auditor-backoffice-ui/src/hooks/order.ts b/packages/auditor-backoffice-ui/src/hooks/order.ts deleted file mode 100644 index e7a893f2c..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/order.ts +++ /dev/null @@ -1,289 +0,0 @@ -/* - 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; - -export interface OrderAPI { - //FIXME: add OutOfStockResponse on 410 - createOrder: ( - data: MerchantBackend.Orders.PostOrderRequest, - ) => Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>>; - forgetOrder: ( - id: string, - data: MerchantBackend.Orders.ForgetRequest, - ) => Promise<HttpResponseOk<void>>; - refundOrder: ( - id: string, - data: MerchantBackend.Orders.RefundRequest, - ) => Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>>; - deleteOrder: (id: string) => Promise<HttpResponseOk<void>>; - getPaymentURL: (id: string) => Promise<HttpResponseOk<string>>; -} - -type YesOrNo = "yes" | "no"; - -export function useOrderAPI(): OrderAPI { - const mutateAll = useMatchMutate(); - const { request } = useBackendInstanceRequest(); - - const createOrder = async ( - data: MerchantBackend.Orders.PostOrderRequest, - ): Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>> => { - const res = await request<MerchantBackend.Orders.PostOrderResponse>( - `/private/orders`, - { - method: "POST", - data, - }, - ); - await mutateAll(/.*private\/orders.*/); - // mutate('') - return res; - }; - const refundOrder = async ( - orderId: string, - data: MerchantBackend.Orders.RefundRequest, - ): Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>> => { - mutateAll(/@"\/private\/orders"@/); - const res = request<MerchantBackend.Orders.MerchantRefundResponse>( - `/private/orders/${orderId}/refund`, - { - method: "POST", - data, - }, - ); - - // order list returns refundable information, so we must evict everything - await mutateAll(/.*private\/orders.*/); - return res; - }; - - const forgetOrder = async ( - orderId: string, - data: MerchantBackend.Orders.ForgetRequest, - ): Promise<HttpResponseOk<void>> => { - mutateAll(/@"\/private\/orders"@/); - const res = request<void>(`/private/orders/${orderId}/forget`, { - method: "PATCH", - data, - }); - // we may be forgetting some fields that are pare of the listing, so we must evict everything - await mutateAll(/.*private\/orders.*/); - return res; - }; - const deleteOrder = async ( - orderId: string, - ): Promise<HttpResponseOk<void>> => { - mutateAll(/@"\/private\/orders"@/); - const res = request<void>(`/private/orders/${orderId}`, { - method: "DELETE", - }); - await mutateAll(/.*private\/orders.*/); - return res; - }; - - const getPaymentURL = async ( - orderId: string, - ): Promise<HttpResponseOk<string>> => { - return request<MerchantBackend.Orders.MerchantOrderStatusResponse>( - `/private/orders/${orderId}`, - { - method: "GET", - }, - ).then((res) => { - const url = - res.data.order_status === "unpaid" - ? res.data.taler_pay_uri - : res.data.contract_terms.fulfillment_url; - const response: HttpResponseOk<string> = res as any; - response.data = url || ""; - return response; - }); - }; - - return { createOrder, forgetOrder, deleteOrder, refundOrder, getPaymentURL }; -} - -export function useOrderDetails( - oderId: string, -): HttpResponse< - MerchantBackend.Orders.MerchantOrderStatusResponse, - MerchantBackend.ErrorDetail -> { - const { fetcher } = useBackendInstanceRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Orders.MerchantOrderStatusResponse>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/orders/${oderId}`], 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 }; -} - -export interface InstanceOrderFilter { - paid?: YesOrNo; - refunded?: YesOrNo; - wired?: YesOrNo; - date?: Date; -} - -export function useInstanceOrders( - args?: InstanceOrderFilter, - updateFilter?: (d: Date) => void, -): HttpResponsePaginated< - MerchantBackend.Orders.OrderHistory, - MerchantBackend.ErrorDetail -> { - const { orderFetcher } = useBackendInstanceRequest(); - - const [pageBefore, setPageBefore] = useState(1); - const [pageAfter, setPageAfter] = useState(1); - - const totalAfter = pageAfter * PAGE_SIZE; - const totalBefore = args?.date ? pageBefore * PAGE_SIZE : 0; - - /** - * FIXME: this can be cleaned up a little - * - * the logic of double query should be inside the orderFetch so from the hook perspective and cache - * is just one query and one error status - */ - const { - data: beforeData, - error: beforeError, - isValidating: loadingBefore, - } = useSWR< - HttpResponseOk<MerchantBackend.Orders.OrderHistory>, - RequestError<MerchantBackend.ErrorDetail> - >( - [ - `/private/orders`, - args?.paid, - args?.refunded, - args?.wired, - args?.date, - totalBefore, - ], - orderFetcher, - ); - const { - data: afterData, - error: afterError, - isValidating: loadingAfter, - } = useSWR< - HttpResponseOk<MerchantBackend.Orders.OrderHistory>, - RequestError<MerchantBackend.ErrorDetail> - >( - [ - `/private/orders`, - args?.paid, - args?.refunded, - args?.wired, - args?.date, - -totalAfter, - ], - orderFetcher, - ); - - //this will save last result - const [lastBefore, setLastBefore] = useState< - HttpResponse< - MerchantBackend.Orders.OrderHistory, - MerchantBackend.ErrorDetail - > - >({ loading: true }); - const [lastAfter, setLastAfter] = useState< - HttpResponse< - MerchantBackend.Orders.OrderHistory, - MerchantBackend.ErrorDetail - > - >({ loading: true }); - useEffect(() => { - if (afterData) setLastAfter(afterData); - if (beforeData) setLastBefore(beforeData); - }, [afterData, beforeData]); - - if (beforeError) return beforeError.cause; - 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.orders.length < totalAfter; - const isReachingStart = - args?.date === undefined || - (beforeData && beforeData.data.orders.length < totalBefore); - - const pagination = { - isReachingEnd, - isReachingStart, - loadMore: () => { - if (!afterData || isReachingEnd) return; - if (afterData.data.orders.length < MAX_RESULT_SIZE) { - setPageAfter(pageAfter + 1); - } else { - const from = - afterData.data.orders[afterData.data.orders.length - 1].timestamp.t_s; - if (from && from !== "never" && updateFilter) - updateFilter(new Date(from * 1000)); - } - }, - loadMorePrev: () => { - if (!beforeData || isReachingStart) return; - if (beforeData.data.orders.length < MAX_RESULT_SIZE) { - setPageBefore(pageBefore + 1); - } else if (beforeData) { - const from = - beforeData.data.orders[beforeData.data.orders.length - 1].timestamp - .t_s; - if (from && from !== "never" && updateFilter) - updateFilter(new Date(from * 1000)); - } - }, - }; - - const orders = - !beforeData || !afterData - ? [] - : (beforeData || lastBefore).data.orders - .slice() - .reverse() - .concat((afterData || lastAfter).data.orders); - if (loadingAfter || loadingBefore) return { loading: true, data: { orders } }; - if (beforeData && afterData) { - return { ok: true, data: { orders }, ...pagination }; - } - return { loading: true }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/otp.ts b/packages/auditor-backoffice-ui/src/hooks/otp.ts deleted file mode 100644 index b045e365a..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/otp.ts +++ /dev/null @@ -1,223 +0,0 @@ -/* - 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_device_description: "first device", - otp_algorithm: 1, - otp_device_id: "1", - otp_key: "123", - }, - "2": { - otp_device_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 = true; - - 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 }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/product.test.ts b/packages/auditor-backoffice-ui/src/hooks/product.test.ts deleted file mode 100644 index 7cac10e25..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/product.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -/* - 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 * as tests from "@gnu-taler/web-util/testing"; -import { expect } from "chai"; -import { MerchantBackend } from "../declaration.js"; -import { - useInstanceProducts, - useProductAPI, - useProductDetails, -} from "./product.js"; -import { ApiMockEnvironment } from "./testing.js"; -import { - API_CREATE_PRODUCT, - API_DELETE_PRODUCT, - API_GET_PRODUCT_BY_ID, - API_LIST_PRODUCTS, - API_UPDATE_PRODUCT_BY_ID, -} from "./urls.js"; - -describe("product api interaction with listing", () => { - it("should evict cache when creating a product", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_PRODUCTS, { - response: { - products: [{ product_id: "1234" }], - }, - }); - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { - response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useInstanceProducts(); - const api = useProductAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).true; - }, - ({ query, api }) => { - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]); - - env.addRequestExpectation(API_CREATE_PRODUCT, { - request: { - price: "ARS:23", - } as MerchantBackend.Products.ProductAddDetail, - }); - - env.addRequestExpectation(API_LIST_PRODUCTS, { - response: { - products: [{ product_id: "1234" }, { product_id: "2345" }], - }, - }); - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { - response: { - price: "ARS:12", - } as MerchantBackend.Products.ProductDetail, - }); - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { - response: { - price: "ARS:12", - } as MerchantBackend.Products.ProductDetail, - }); - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), { - response: { - price: "ARS:23", - } as MerchantBackend.Products.ProductDetail, - }); - - api.createProduct({ - price: "ARS:23", - } as any); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals([ - { - id: "1234", - price: "ARS:12", - }, - { - id: "2345", - price: "ARS:23", - }, - ]); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when updating a product", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_PRODUCTS, { - response: { - products: [{ product_id: "1234" }], - }, - }); - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { - response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useInstanceProducts(); - const api = useProductAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]); - - env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("1234"), { - request: { - price: "ARS:13", - } as MerchantBackend.Products.ProductPatchDetail, - }); - - env.addRequestExpectation(API_LIST_PRODUCTS, { - response: { - products: [{ product_id: "1234" }], - }, - }); - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { - response: { - price: "ARS:13", - } as MerchantBackend.Products.ProductDetail, - }); - - api.updateProduct("1234", { - price: "ARS:13", - } as any); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals([ - { - id: "1234", - price: "ARS:13", - }, - ]); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when deleting a product", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_PRODUCTS, { - response: { - products: [{ product_id: "1234" }, { product_id: "2345" }], - }, - }); - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { - response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail, - }); - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), { - response: { price: "ARS:23" } as MerchantBackend.Products.ProductDetail, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useInstanceProducts(); - const api = useProductAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).true; - }, - ({ query, api }) => { - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals([ - { id: "1234", price: "ARS:12" }, - { id: "2345", price: "ARS:23" }, - ]); - - env.addRequestExpectation(API_DELETE_PRODUCT("2345"), {}); - - env.addRequestExpectation(API_LIST_PRODUCTS, { - response: { - products: [{ product_id: "1234" }], - }, - }); - - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { - response: { - price: "ARS:12", - } as MerchantBackend.Products.ProductDetail, - }); - api.deleteProduct("2345"); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); - -describe("product api interaction with details", () => { - it("should evict cache when updating a product", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), { - response: { - description: "this is a description", - } as MerchantBackend.Products.ProductDetail, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useProductDetails("12"); - const api = useProductAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - description: "this is a description", - }); - - env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("12"), { - request: { - description: "other description", - } as MerchantBackend.Products.ProductPatchDetail, - }); - - env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), { - response: { - description: "other description", - } as MerchantBackend.Products.ProductDetail, - }); - - api.updateProduct("12", { - description: "other description", - } as any); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - description: "other description", - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); diff --git a/packages/auditor-backoffice-ui/src/hooks/product.ts b/packages/auditor-backoffice-ui/src/hooks/product.ts deleted file mode 100644 index 8ca8d2724..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/product.ts +++ /dev/null @@ -1,177 +0,0 @@ -/* - 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, - RequestError, -} from "@gnu-taler/web-util/browser"; -import { MerchantBackend, WithId } from "../declaration.js"; -import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; - -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook, useSWRConfig } from "swr"; -const useSWR = _useSWR as unknown as SWRHook; - -export interface ProductAPI { - getProduct: ( - id: string, - ) => Promise<void>; - createProduct: ( - data: MerchantBackend.Products.ProductAddDetail, - ) => Promise<void>; - updateProduct: ( - id: string, - data: MerchantBackend.Products.ProductPatchDetail, - ) => Promise<void>; - deleteProduct: (id: string) => Promise<void>; - lockProduct: ( - id: string, - data: MerchantBackend.Products.LockRequest, - ) => Promise<void>; -} - -export function useProductAPI(): ProductAPI { - const mutateAll = useMatchMutate(); - const { mutate } = useSWRConfig(); - - const { request } = useBackendInstanceRequest(); - - const createProduct = async ( - data: MerchantBackend.Products.ProductAddDetail, - ): Promise<void> => { - const res = await request(`/private/products`, { - method: "POST", - data, - }); - - return await mutateAll(/.*\/private\/products.*/); - }; - - const updateProduct = async ( - productId: string, - data: MerchantBackend.Products.ProductPatchDetail, - ): Promise<void> => { - const r = await request(`/private/products/${productId}`, { - method: "PATCH", - data, - }); - - return await mutateAll(/.*\/private\/products.*/); - }; - - const deleteProduct = async (productId: string): Promise<void> => { - await request(`/private/products/${productId}`, { - method: "DELETE", - }); - await mutate([`/private/products`]); - }; - - const lockProduct = async ( - productId: string, - data: MerchantBackend.Products.LockRequest, - ): Promise<void> => { - await request(`/private/products/${productId}/lock`, { - method: "POST", - data, - }); - - return await mutateAll(/.*"\/private\/products.*/); - }; - - const getProduct = async ( - productId: string, - ): Promise<void> => { - await request(`/private/products/${productId}`, { - method: "GET", - }); - - return - }; - - return { createProduct, updateProduct, deleteProduct, lockProduct, getProduct }; -} - -export function useInstanceProducts(): HttpResponse< - (MerchantBackend.Products.ProductDetail & WithId)[], - MerchantBackend.ErrorDetail -> { - const { fetcher, multiFetcher } = useBackendInstanceRequest(); - - const { data: list, error: listError } = useSWR< - HttpResponseOk<MerchantBackend.Products.InventorySummaryResponse>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/products`], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - }); - - const paths = (list?.data.products || []).map( - (p) => `/private/products/${p.product_id}`, - ); - const { data: products, error: productError } = useSWR< - HttpResponseOk<MerchantBackend.Products.ProductDetail>[], - RequestError<MerchantBackend.ErrorDetail> - >([paths], multiFetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - }); - - if (listError) return listError.cause; - if (productError) return productError.cause; - - if (products) { - const dataWithId = products.map((d) => { - //take the id from the queried url - return { - ...d.data, - id: d.info?.url.replace(/.*\/private\/products\//, "") || "", - }; - }); - return { ok: true, data: dataWithId }; - } - return { loading: true }; -} - -export function useProductDetails( - productId: string, -): HttpResponse< - MerchantBackend.Products.ProductDetail, - MerchantBackend.ErrorDetail -> { - const { fetcher } = useBackendInstanceRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Products.ProductDetail>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/products/${productId}`], 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 }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts b/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts deleted file mode 100644 index b3eecd754..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -/* - 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 { expect } from "chai"; -import { MerchantBackend } from "../declaration.js"; -import { - useInstanceReserves, - useReserveDetails, - useReservesAPI, - useRewardDetails, -} from "./reserves.js"; -import { ApiMockEnvironment } from "./testing.js"; -import { - API_AUTHORIZE_REWARD, - API_AUTHORIZE_REWARD_FOR_RESERVE, - API_CREATE_RESERVE, - API_DELETE_RESERVE, - API_GET_RESERVE_BY_ID, - API_GET_REWARD_BY_ID, - API_LIST_RESERVES, -} from "./urls.js"; -import * as tests from "@gnu-taler/web-util/testing"; - -describe("reserve api interaction with listing", () => { - it("should evict cache when creating a reserve", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_RESERVES, { - response: { - reserves: [ - { - reserve_pub: "11", - } as MerchantBackend.Rewards.ReserveStatusEntry, - ], - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useReservesAPI(); - const query = useInstanceReserves(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - reserves: [{ reserve_pub: "11" }], - }); - - env.addRequestExpectation(API_CREATE_RESERVE, { - request: { - initial_balance: "ARS:3333", - exchange_url: "http://url", - wire_method: "iban", - }, - response: { - reserve_pub: "22", - accounts: [], - }, - }); - - env.addRequestExpectation(API_LIST_RESERVES, { - response: { - reserves: [ - { - reserve_pub: "11", - } as MerchantBackend.Rewards.ReserveStatusEntry, - { - reserve_pub: "22", - } as MerchantBackend.Rewards.ReserveStatusEntry, - ], - }, - }); - - api.createReserve({ - initial_balance: "ARS:3333", - exchange_url: "http://url", - wire_method: "iban", - }); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - - expect(query.data).deep.equals({ - reserves: [ - { - reserve_pub: "11", - } as MerchantBackend.Rewards.ReserveStatusEntry, - { - reserve_pub: "22", - } as MerchantBackend.Rewards.ReserveStatusEntry, - ], - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when deleting a reserve", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_RESERVES, { - response: { - reserves: [ - { - reserve_pub: "11", - } as MerchantBackend.Rewards.ReserveStatusEntry, - { - reserve_pub: "22", - } as MerchantBackend.Rewards.ReserveStatusEntry, - { - reserve_pub: "33", - } as MerchantBackend.Rewards.ReserveStatusEntry, - ], - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useReservesAPI(); - const query = useInstanceReserves(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - reserves: [ - { reserve_pub: "11" }, - { reserve_pub: "22" }, - { reserve_pub: "33" }, - ], - }); - - env.addRequestExpectation(API_DELETE_RESERVE("11"), {}); - env.addRequestExpectation(API_LIST_RESERVES, { - response: { - reserves: [ - { - reserve_pub: "22", - } as MerchantBackend.Rewards.ReserveStatusEntry, - { - reserve_pub: "33", - } as MerchantBackend.Rewards.ReserveStatusEntry, - ], - }, - }); - - api.deleteReserve("11"); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - reserves: [{ reserve_pub: "22" }, { reserve_pub: "33" }], - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); - -describe("reserve api interaction with details", () => { - it("should evict cache when adding a reward for a specific reserve", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { - response: { - accounts: [{ payto_uri: "payto://here" }], - rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], - } as MerchantBackend.Rewards.ReserveDetail, - qparam: { - rewards: "yes", - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useReservesAPI(); - const query = useReserveDetails("11"); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - accounts: [{ payto_uri: "payto://here" }], - rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], - }); - - env.addRequestExpectation(API_AUTHORIZE_REWARD_FOR_RESERVE("11"), { - request: { - amount: "USD:12", - justification: "not", - next_url: "http://taler.net", - }, - response: { - reward_id: "id2", - taler_reward_uri: "uri", - reward_expiration: { t_s: 1 }, - reward_status_url: "url", - }, - }); - - env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { - response: { - accounts: [{ payto_uri: "payto://here" }], - rewards: [ - { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, - { reason: "not", reward_id: "id2", total_amount: "USD:12" }, - ], - } as MerchantBackend.Rewards.ReserveDetail, - qparam: { - rewards: "yes", - }, - }); - - api.authorizeRewardReserve("11", { - amount: "USD:12", - justification: "not", - next_url: "http://taler.net", - }); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - - expect(query.data).deep.equals({ - accounts: [{ payto_uri: "payto://here" }], - rewards: [ - { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, - { reason: "not", reward_id: "id2", total_amount: "USD:12" }, - ], - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should evict cache when adding a reward for a random reserve", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { - response: { - accounts: [{ payto_uri: "payto://here" }], - rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], - } as MerchantBackend.Rewards.ReserveDetail, - qparam: { - rewards: "yes", - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const api = useReservesAPI(); - const query = useReserveDetails("11"); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - accounts: [{ payto_uri: "payto://here" }], - rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], - }); - - env.addRequestExpectation(API_AUTHORIZE_REWARD, { - request: { - amount: "USD:12", - justification: "not", - next_url: "http://taler.net", - }, - response: { - reward_id: "id2", - taler_reward_uri: "uri", - reward_expiration: { t_s: 1 }, - reward_status_url: "url", - }, - }); - - env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { - response: { - accounts: [{ payto_uri: "payto://here" }], - rewards: [ - { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, - { reason: "not", reward_id: "id2", total_amount: "USD:12" }, - ], - } as MerchantBackend.Rewards.ReserveDetail, - qparam: { - rewards: "yes", - }, - }); - - api.authorizeReward({ - amount: "USD:12", - justification: "not", - next_url: "http://taler.net", - }); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - - expect(query.data).deep.equals({ - accounts: [{ payto_uri: "payto://here" }], - rewards: [ - { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, - { reason: "not", reward_id: "id2", total_amount: "USD:12" }, - ], - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); - -describe("reserve api interaction with reward details", () => { - it("should list rewards", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_GET_REWARD_BY_ID("11"), { - response: { - total_picked_up: "USD:12", - reason: "not", - } as MerchantBackend.Rewards.RewardDetails, - qparam: { - pickups: "yes", - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useRewardDetails("11"); - return { query }; - }, - {}, - [ - ({ query }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).true; - }, - ({ query }) => { - expect(query.loading).false; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - total_picked_up: "USD:12", - reason: "not", - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); diff --git a/packages/auditor-backoffice-ui/src/hooks/reserves.ts b/packages/auditor-backoffice-ui/src/hooks/reserves.ts deleted file mode 100644 index b719bfbe6..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/reserves.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - 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, - RequestError, -} from "@gnu-taler/web-util/browser"; -import { MerchantBackend } from "../declaration.js"; -import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; - -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook, useSWRConfig } from "swr"; -const useSWR = _useSWR as unknown as SWRHook; - -export function useReservesAPI(): ReserveMutateAPI { - const mutateAll = useMatchMutate(); - const { mutate } = useSWRConfig(); - const { request } = useBackendInstanceRequest(); - - const createReserve = async ( - data: MerchantBackend.Rewards.ReserveCreateRequest, - ): Promise< - HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation> - > => { - const res = await request<MerchantBackend.Rewards.ReserveCreateConfirmation>( - `/private/reserves`, - { - method: "POST", - data, - }, - ); - - //evict reserve list query - await mutateAll(/.*private\/reserves.*/); - - return res; - }; - - const authorizeRewardReserve = async ( - pub: string, - data: MerchantBackend.Rewards.RewardCreateRequest, - ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => { - const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>( - `/private/reserves/${pub}/authorize-reward`, - { - method: "POST", - data, - }, - ); - - //evict reserve details query - await mutate([`/private/reserves/${pub}`]); - - return res; - }; - - const authorizeReward = async ( - data: MerchantBackend.Rewards.RewardCreateRequest, - ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => { - const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>( - `/private/rewards`, - { - method: "POST", - data, - }, - ); - - //evict all details query - await mutateAll(/.*private\/reserves\/.*/); - - return res; - }; - - const deleteReserve = async ( - pub: string, - ): Promise<HttpResponse<void, MerchantBackend.ErrorDetail>> => { - const res = await request<void>(`/private/reserves/${pub}`, { - method: "DELETE", - }); - - //evict reserve list query - await mutateAll(/.*private\/reserves.*/); - - return res; - }; - - return { createReserve, authorizeReward, authorizeRewardReserve, deleteReserve }; -} - -export interface ReserveMutateAPI { - createReserve: ( - data: MerchantBackend.Rewards.ReserveCreateRequest, - ) => Promise<HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>>; - authorizeRewardReserve: ( - id: string, - data: MerchantBackend.Rewards.RewardCreateRequest, - ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>; - authorizeReward: ( - data: MerchantBackend.Rewards.RewardCreateRequest, - ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>; - deleteReserve: ( - id: string, - ) => Promise<HttpResponse<void, MerchantBackend.ErrorDetail>>; -} - -export function useInstanceReserves(): HttpResponse< - MerchantBackend.Rewards.RewardReserveStatus, - MerchantBackend.ErrorDetail -> { - const { fetcher } = useBackendInstanceRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Rewards.RewardReserveStatus>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/reserves`], fetcher); - - if (isValidating) return { loading: true, data: data?.data }; - if (data) return data; - if (error) return error.cause; - return { loading: true }; -} - -export function useReserveDetails( - reserveId: string, -): HttpResponse< - MerchantBackend.Rewards.ReserveDetail, - MerchantBackend.ErrorDetail -> { - const { reserveDetailFetcher } = useBackendInstanceRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Rewards.ReserveDetail>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/reserves/${reserveId}`], reserveDetailFetcher, { - 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 }; -} - -export function useRewardDetails( - rewardId: string, -): HttpResponse<MerchantBackend.Rewards.RewardDetails, MerchantBackend.ErrorDetail> { - const { rewardsDetailFetcher } = useBackendInstanceRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Rewards.RewardDetails>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/rewards/${rewardId}`], rewardsDetailFetcher, { - 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 }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/templates.ts b/packages/auditor-backoffice-ui/src/hooks/templates.ts deleted file mode 100644 index ee8728cc8..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/templates.ts +++ /dev/null @@ -1,266 +0,0 @@ -/* - 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; - -export function useTemplateAPI(): TemplateAPI { - const mutateAll = useMatchMutate(); - const { request } = useBackendInstanceRequest(); - - const createTemplate = async ( - data: MerchantBackend.Template.TemplateAddDetails, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`/private/templates`, { - method: "POST", - data, - }); - await mutateAll(/.*private\/templates.*/); - return res; - }; - - const updateTemplate = async ( - templateId: string, - data: MerchantBackend.Template.TemplatePatchDetails, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`/private/templates/${templateId}`, { - method: "PATCH", - data, - }); - await mutateAll(/.*private\/templates.*/); - return res; - }; - - const deleteTemplate = async ( - templateId: string, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`/private/templates/${templateId}`, { - method: "DELETE", - }); - await mutateAll(/.*private\/templates.*/); - return res; - }; - - const createOrderFromTemplate = async ( - templateId: string, - data: MerchantBackend.Template.UsingTemplateDetails, - ): Promise< - HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse> - > => { - const res = await request<MerchantBackend.Template.UsingTemplateResponse>( - `/templates/${templateId}`, - { - method: "POST", - data, - }, - ); - await mutateAll(/.*private\/templates.*/); - return res; - }; - - const testTemplateExist = async ( - templateId: string, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`/private/templates/${templateId}`, { method: "GET", }); - return res; - }; - - - return { - createTemplate, - updateTemplate, - deleteTemplate, - testTemplateExist, - createOrderFromTemplate, - }; -} - -export interface TemplateAPI { - createTemplate: ( - data: MerchantBackend.Template.TemplateAddDetails, - ) => Promise<HttpResponseOk<void>>; - updateTemplate: ( - id: string, - data: MerchantBackend.Template.TemplatePatchDetails, - ) => Promise<HttpResponseOk<void>>; - testTemplateExist: ( - id: string - ) => Promise<HttpResponseOk<void>>; - deleteTemplate: (id: string) => Promise<HttpResponseOk<void>>; - createOrderFromTemplate: ( - id: string, - data: MerchantBackend.Template.UsingTemplateDetails, - ) => Promise<HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>>; -} - -export interface InstanceTemplateFilter { - //FIXME: add filter to the template list - position?: string; -} - -export function useInstanceTemplates( - args?: InstanceTemplateFilter, - updatePosition?: (id: string) => void, -): HttpResponsePaginated< - MerchantBackend.Template.TemplateSummaryResponse, - MerchantBackend.ErrorDetail -> { - const { templateFetcher } = useBackendInstanceRequest(); - - const [pageBefore, setPageBefore] = useState(1); - const [pageAfter, setPageAfter] = useState(1); - - const totalAfter = pageAfter * PAGE_SIZE; - const totalBefore = args?.position ? pageBefore * PAGE_SIZE : 0; - - /** - * FIXME: this can be cleaned up a little - * - * the logic of double query should be inside the orderFetch so from the hook perspective and cache - * is just one query and one error status - */ - const { - data: beforeData, - error: beforeError, - isValidating: loadingBefore, - } = useSWR< - HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>, - RequestError<MerchantBackend.ErrorDetail>>( - [ - `/private/templates`, - args?.position, - totalBefore, - ], - templateFetcher, - ); - const { - data: afterData, - error: afterError, - isValidating: loadingAfter, - } = useSWR< - HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/templates`, args?.position, -totalAfter], templateFetcher); - - //this will save last result - const [lastBefore, setLastBefore] = useState< - HttpResponse< - MerchantBackend.Template.TemplateSummaryResponse, - MerchantBackend.ErrorDetail - > - >({ loading: true }); - - const [lastAfter, setLastAfter] = useState< - HttpResponse< - MerchantBackend.Template.TemplateSummaryResponse, - MerchantBackend.ErrorDetail - > - >({ loading: true }); - useEffect(() => { - if (afterData) setLastAfter(afterData); - if (beforeData) setLastBefore(beforeData); - }, [afterData, beforeData]); - - if (beforeError) return beforeError.cause; - 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.templates.length < totalAfter; - const isReachingStart = args?.position === undefined - || - (beforeData && beforeData.data.templates.length < totalBefore); - - const pagination = { - isReachingEnd, - isReachingStart, - loadMore: () => { - if (!afterData || isReachingEnd) return; - if (afterData.data.templates.length < MAX_RESULT_SIZE) { - setPageAfter(pageAfter + 1); - } else { - const from = `${afterData.data.templates[afterData.data.templates.length - 1] - .template_id - }`; - if (from && updatePosition) updatePosition(from); - } - }, - loadMorePrev: () => { - if (!beforeData || isReachingStart) return; - if (beforeData.data.templates.length < MAX_RESULT_SIZE) { - setPageBefore(pageBefore + 1); - } else if (beforeData) { - const from = `${beforeData.data.templates[beforeData.data.templates.length - 1] - .template_id - }`; - if (from && updatePosition) updatePosition(from); - } - }, - }; - - // const templates = !afterData ? [] : (afterData || lastAfter).data.templates; - const templates = - !beforeData || !afterData - ? [] - : (beforeData || lastBefore).data.templates - .slice() - .reverse() - .concat((afterData || lastAfter).data.templates); - if (loadingAfter || loadingBefore) - return { loading: true, data: { templates } }; - if (beforeData && afterData) { - return { ok: true, data: { templates }, ...pagination }; - } - return { loading: true }; -} - -export function useTemplateDetails( - templateId: string, -): HttpResponse< - MerchantBackend.Template.TemplateDetails, - MerchantBackend.ErrorDetail -> { - const { templateFetcher } = useBackendInstanceRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Template.TemplateDetails>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/templates/${templateId}`], templateFetcher, { - 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 }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/testing.tsx b/packages/auditor-backoffice-ui/src/hooks/testing.tsx deleted file mode 100644 index 7955f832a..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/testing.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/* - 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 { MockEnvironment } from "@gnu-taler/web-util/testing"; -import { ComponentChildren, FunctionalComponent, h, VNode } from "preact"; -import { HttpRequestLibrary, HttpRequestOptions, HttpResponse } from "@gnu-taler/taler-util/http"; -import { SWRConfig } from "swr"; -import { ApiContextProvider } from "@gnu-taler/web-util/browser"; -import { BackendContextProvider } from "../context/backend.js"; -import { InstanceContextProvider } from "../context/instance.js"; -import { HttpResponseOk, RequestOptions } from "@gnu-taler/web-util/browser"; -import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util"; - -export class ApiMockEnvironment extends MockEnvironment { - constructor(debug = false) { - super(debug); - } - - mockApiIfNeeded(): void { - null; // do nothing - } - - public buildTestingContext(): FunctionalComponent<{ - children: ComponentChildren; - }> { - const __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE = - this.saveRequestAndGetMockedResponse.bind(this); - - return function TestingContext({ - children, - }: { - children: ComponentChildren; - }): VNode { - - async function request<T>( - base: string, - path: string, - options: RequestOptions = {}, - ): Promise<HttpResponseOk<T>> { - const _url = new URL(`${base}${path}`); - // Object.entries(options.params ?? {}).forEach(([key, value]) => { - // _url.searchParams.set(key, String(value)); - // }); - - const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE( - { - method: options.method ?? "GET", - url: _url.href, - }, - { - qparam: options.params, - auth: options.token, - request: options.data, - }, - ); - const status = mocked.expectedQuery?.query.code ?? 200; - const requestPayload = mocked.expectedQuery?.params?.request; - const responsePayload = mocked.expectedQuery?.params?.response; - - return { - ok: true, - data: responsePayload as T, - loading: false, - clientError: false, - serverError: false, - info: { - hasToken: !!options.token, - status, - url: _url.href, - payload: options.data, - options: {}, - }, - }; - } - const SC: any = SWRConfig; - - const mockHttpClient = new class implements HttpRequestLibrary { - async fetch(url: string, options?: HttpRequestOptions | undefined): Promise<HttpResponse> { - const _url = new URL(url); - const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE( - { - method: options?.method ?? "GET", - url: _url.href, - }, - { - qparam: _url.searchParams, - auth: options as any, - request: options?.body as any, - }, - ); - const status = mocked.expectedQuery?.query.code ?? 200; - const requestPayload = mocked.expectedQuery?.params?.request; - const responsePayload = mocked.expectedQuery?.params?.response; - - // FIXME: complete this implementation to mock any query - const resp: HttpResponse = { - requestUrl: _url.href, - status: status, - headers: {} as any, - requestMethod: options?.method ?? "GET", - json: async () => responsePayload, - text: async () => responsePayload as any as string, - bytes: async () => responsePayload as ArrayBuffer, - }; - return resp - } - get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { - return this.fetch(url, { - method: "GET", - ...opt, - }); - } - - postJson( - url: string, - body: any, - opt?: HttpRequestOptions, - ): Promise<HttpResponse> { - return this.fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - ...opt, - }); - } - - } - const bankCore = new TalerCoreBankHttpClient("http://localhost", mockHttpClient) - const bankIntegration = new TalerBankIntegrationHttpClient(bankCore.getIntegrationAPI().href, mockHttpClient) - const bankRevenue = new TalerRevenueHttpClient(bankCore.getRevenueAPI("a").href, mockHttpClient) - const bankWire = new TalerWireGatewayHttpClient(bankCore.getWireGatewayAPI("b").href, "b", mockHttpClient) - - return ( - <BackendContextProvider defaultUrl="http://backend"> - <InstanceContextProvider - value={{ - token: undefined, - id: "default", - admin: true, - changeToken: () => null, - }} - > - <ApiContextProvider value={{ request, bankCore, bankIntegration, bankRevenue, bankWire }}> - <SC - value={{ - loadingTimeout: 0, - dedupingInterval: 0, - shouldRetryOnError: false, - errorRetryInterval: 0, - errorRetryCount: 0, - provider: () => new Map(), - }} - > - {children} - </SC> - </ApiContextProvider> - </InstanceContextProvider> - </BackendContextProvider> - ); - }; - } -} diff --git a/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts b/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts deleted file mode 100644 index a7187af27..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -/* - 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 * as tests from "@gnu-taler/web-util/testing"; -import { expect } from "chai"; -import { MerchantBackend } from "../declaration.js"; -import { API_INFORM_TRANSFERS, API_LIST_TRANSFERS } from "./urls.js"; -import { ApiMockEnvironment } from "./testing.js"; -import { useInstanceTransfers, useTransferAPI } from "./transfer.js"; - -describe("transfer api interaction with listing", () => { - it("should evict cache when informing a transfer", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_TRANSFERS, { - qparam: { limit: -20 }, - response: { - transfers: [{ wtid: "2" } as MerchantBackend.Transfers.TransferDetails], - }, - }); - - const moveCursor = (d: string) => { - console.log("new position", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const query = useInstanceTransfers({}, moveCursor); - const api = useTransferAPI(); - return { query, api }; - }, - {}, - [ - ({ query, api }) => { - expect(query.loading).true; - }, - - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - transfers: [{ wtid: "2" }], - }); - - env.addRequestExpectation(API_INFORM_TRANSFERS, { - request: { - wtid: "3", - credit_amount: "EUR:1", - exchange_url: "exchange.url", - payto_uri: "payto://", - }, - response: { total: "" } as any, - }); - - env.addRequestExpectation(API_LIST_TRANSFERS, { - qparam: { limit: -20 }, - response: { - transfers: [{ wtid: "3" } as any, { wtid: "2" } as any], - }, - }); - - api.informTransfer({ - wtid: "3", - credit_amount: "EUR:1", - exchange_url: "exchange.url", - payto_uri: "payto://", - }); - }, - ({ query, api }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - - expect(query.data).deep.equals({ - transfers: [{ wtid: "3" }, { wtid: "2" }], - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); - -describe("transfer listing pagination", () => { - it("should not load more if has reach the end", async () => { - const env = new ApiMockEnvironment(); - - env.addRequestExpectation(API_LIST_TRANSFERS, { - qparam: { limit: -20, payto_uri: "payto://" }, - response: { - transfers: [{ wtid: "2" }, { wtid: "1" } as any], - }, - }); - - const moveCursor = (d: string) => { - console.log("new position", d); - }; - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - return useInstanceTransfers({ payto_uri: "payto://" }, moveCursor); - }, - {}, - [ - (query) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(query.loading).true; - }, - (query) => { - expect(query.loading).undefined; - expect(query.ok).true; - if (!query.ok) return; - expect(query.data).deep.equals({ - transfers: [{ wtid: "2" }, { wtid: "1" }], - }); - expect(query.isReachingEnd).true; - expect(query.isReachingStart).true; - - //check that this button won't trigger more updates since - //has reach end and start - query.loadMore(); - query.loadMorePrev(); - }, - ], - env.buildTestingContext(), - ); - - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - expect(hookBehavior).deep.eq({ result: "ok" }); - }); - - it("should load more if result brings more that PAGE_SIZE", async () => { - const env = new ApiMockEnvironment(); - - const transfersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({ - wtid: String(i), - })); - const transfersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({ - wtid: String(i + 20), - })); - const transfersFrom20to0 = [...transfersFrom0to20].reverse(); - - env.addRequestExpectation(API_LIST_TRANSFERS, { - qparam: { limit: 20, payto_uri: "payto://", offset: "1" }, - response: { - transfers: transfersFrom0to20, - }, - }); - - env.addRequestExpectation(API_LIST_TRANSFERS, { - qparam: { limit: -20, payto_uri: "payto://", offset: "1" }, - response: { - transfers: transfersFrom20to40, - }, - }); - - const moveCursor = (d: string) => { - console.log("new position", d); - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - return useInstanceTransfers( - { payto_uri: "payto://", position: "1" }, - moveCursor, - ); - }, - {}, - [ - (result) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(result.loading).true; - }, - (result) => { - expect(result.loading).undefined; - expect(result.ok).true; - if (!result.ok) return; - expect(result.data).deep.equals({ - transfers: [...transfersFrom20to0, ...transfersFrom20to40], - }); - expect(result.isReachingEnd).false; - expect(result.isReachingStart).false; - - //query more - env.addRequestExpectation(API_LIST_TRANSFERS, { - qparam: { limit: -40, payto_uri: "payto://", offset: "1" }, - response: { - transfers: [...transfersFrom20to40, { wtid: "41" }], - }, - }); - result.loadMore(); - }, - (result) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(result.loading).true; - }, - (result) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - expect(result.loading).undefined; - expect(result.ok).true; - if (!result.ok) return; - expect(result.data).deep.equals({ - transfers: [ - ...transfersFrom20to0, - ...transfersFrom20to40, - { wtid: "41" }, - ], - }); - expect(result.isReachingEnd).true; - expect(result.isReachingStart).false; - }, - ], - env.buildTestingContext(), - ); - - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - expect(hookBehavior).deep.eq({ result: "ok" }); - }); -}); diff --git a/packages/auditor-backoffice-ui/src/hooks/transfer.ts b/packages/auditor-backoffice-ui/src/hooks/transfer.ts deleted file mode 100644 index 27c3bdc75..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/transfer.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - 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; - -export function useTransferAPI(): TransferAPI { - const mutateAll = useMatchMutate(); - const { request } = useBackendInstanceRequest(); - - const informTransfer = async ( - data: MerchantBackend.Transfers.TransferInformation, - ): Promise<HttpResponseOk<{}>> => { - const res = await request<{}>(`/private/transfers`, { - method: "POST", - data, - }); - - await mutateAll(/.*private\/transfers.*/); - return res; - }; - - return { informTransfer }; -} - -export interface TransferAPI { - informTransfer: ( - data: MerchantBackend.Transfers.TransferInformation, - ) => Promise<HttpResponseOk<{}>>; -} - -export interface InstanceTransferFilter { - payto_uri?: string; - verified?: "yes" | "no"; - position?: string; -} - -export function useInstanceTransfers( - args?: InstanceTransferFilter, - updatePosition?: (id: string) => void, -): HttpResponsePaginated< - MerchantBackend.Transfers.TransferList, - MerchantBackend.ErrorDetail -> { - const { transferFetcher } = useBackendInstanceRequest(); - - const [pageBefore, setPageBefore] = useState(1); - const [pageAfter, setPageAfter] = useState(1); - - const totalAfter = pageAfter * PAGE_SIZE; - const totalBefore = args?.position !== undefined ? pageBefore * PAGE_SIZE : 0; - - /** - * FIXME: this can be cleaned up a little - * - * the logic of double query should be inside the orderFetch so from the hook perspective and cache - * is just one query and one error status - */ - const { - data: beforeData, - error: beforeError, - isValidating: loadingBefore, - } = useSWR< - HttpResponseOk<MerchantBackend.Transfers.TransferList>, - RequestError<MerchantBackend.ErrorDetail> - >( - [ - `/private/transfers`, - args?.payto_uri, - args?.verified, - args?.position, - totalBefore, - ], - transferFetcher, - ); - const { - data: afterData, - error: afterError, - isValidating: loadingAfter, - } = useSWR< - HttpResponseOk<MerchantBackend.Transfers.TransferList>, - RequestError<MerchantBackend.ErrorDetail> - >( - [ - `/private/transfers`, - args?.payto_uri, - args?.verified, - args?.position, - -totalAfter, - ], - transferFetcher, - ); - - //this will save last result - const [lastBefore, setLastBefore] = useState< - HttpResponse< - MerchantBackend.Transfers.TransferList, - MerchantBackend.ErrorDetail - > - >({ loading: true }); - const [lastAfter, setLastAfter] = useState< - HttpResponse< - MerchantBackend.Transfers.TransferList, - MerchantBackend.ErrorDetail - > - >({ loading: true }); - useEffect(() => { - if (afterData) setLastAfter(afterData); - if (beforeData) setLastBefore(beforeData); - }, [afterData, beforeData]); - - if (beforeError) return beforeError.cause; - 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.transfers.length < totalAfter; - const isReachingStart = - args?.position === undefined || - (beforeData && beforeData.data.transfers.length < totalBefore); - - const pagination = { - isReachingEnd, - isReachingStart, - loadMore: () => { - if (!afterData || isReachingEnd) return; - if (afterData.data.transfers.length < MAX_RESULT_SIZE) { - setPageAfter(pageAfter + 1); - } else { - const from = `${ - afterData.data.transfers[afterData.data.transfers.length - 1] - .transfer_serial_id - }`; - if (from && updatePosition) updatePosition(from); - } - }, - loadMorePrev: () => { - if (!beforeData || isReachingStart) return; - if (beforeData.data.transfers.length < MAX_RESULT_SIZE) { - setPageBefore(pageBefore + 1); - } else if (beforeData) { - const from = `${ - beforeData.data.transfers[beforeData.data.transfers.length - 1] - .transfer_serial_id - }`; - if (from && updatePosition) updatePosition(from); - } - }, - }; - - const transfers = - !beforeData || !afterData - ? [] - : (beforeData || lastBefore).data.transfers - .slice() - .reverse() - .concat((afterData || lastAfter).data.transfers); - if (loadingAfter || loadingBefore) - return { loading: true, data: { transfers } }; - if (beforeData && afterData) { - return { ok: true, data: { transfers }, ...pagination }; - } - return { loading: true }; -} diff --git a/packages/auditor-backoffice-ui/src/hooks/urls.ts b/packages/auditor-backoffice-ui/src/hooks/urls.ts deleted file mode 100644 index b6485259f..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/urls.ts +++ /dev/null @@ -1,303 +0,0 @@ -/* - 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 { Query } from "@gnu-taler/web-util/testing"; -import { MerchantBackend } from "../declaration.js"; - -//////////////////// -// ORDER -//////////////////// - -export const API_CREATE_ORDER: Query< - MerchantBackend.Orders.PostOrderRequest, - MerchantBackend.Orders.PostOrderResponse -> = { - method: "POST", - url: "http://backend/instances/default/private/orders", -}; - -export const API_GET_ORDER_BY_ID = ( - id: string, -): Query<unknown, MerchantBackend.Orders.MerchantOrderStatusResponse> => ({ - method: "GET", - url: `http://backend/instances/default/private/orders/${id}`, -}); - -export const API_LIST_ORDERS: Query< - unknown, - MerchantBackend.Orders.OrderHistory -> = { - method: "GET", - url: "http://backend/instances/default/private/orders", -}; - -export const API_REFUND_ORDER_BY_ID = ( - id: string, -): Query< - MerchantBackend.Orders.RefundRequest, - MerchantBackend.Orders.MerchantRefundResponse -> => ({ - method: "POST", - url: `http://backend/instances/default/private/orders/${id}/refund`, -}); - -export const API_FORGET_ORDER_BY_ID = ( - id: string, -): Query<MerchantBackend.Orders.ForgetRequest, unknown> => ({ - method: "PATCH", - url: `http://backend/instances/default/private/orders/${id}/forget`, -}); - -export const API_DELETE_ORDER = ( - id: string, -): Query<MerchantBackend.Orders.ForgetRequest, unknown> => ({ - method: "DELETE", - url: `http://backend/instances/default/private/orders/${id}`, -}); - -//////////////////// -// TRANSFER -//////////////////// - -export const API_LIST_TRANSFERS: Query< - unknown, - MerchantBackend.Transfers.TransferList -> = { - method: "GET", - url: "http://backend/instances/default/private/transfers", -}; - -export const API_INFORM_TRANSFERS: Query< - MerchantBackend.Transfers.TransferInformation, - {} -> = { - method: "POST", - url: "http://backend/instances/default/private/transfers", -}; - -//////////////////// -// PRODUCT -//////////////////// - -export const API_CREATE_PRODUCT: Query< - MerchantBackend.Products.ProductAddDetail, - unknown -> = { - method: "POST", - url: "http://backend/instances/default/private/products", -}; - -export const API_LIST_PRODUCTS: Query< - unknown, - MerchantBackend.Products.InventorySummaryResponse -> = { - method: "GET", - url: "http://backend/instances/default/private/products", -}; - -export const API_GET_PRODUCT_BY_ID = ( - id: string, -): Query<unknown, MerchantBackend.Products.ProductDetail> => ({ - method: "GET", - url: `http://backend/instances/default/private/products/${id}`, -}); - -export const API_UPDATE_PRODUCT_BY_ID = ( - id: string, -): Query< - MerchantBackend.Products.ProductPatchDetail, - MerchantBackend.Products.InventorySummaryResponse -> => ({ - method: "PATCH", - url: `http://backend/instances/default/private/products/${id}`, -}); - -export const API_DELETE_PRODUCT = (id: string): Query<unknown, unknown> => ({ - method: "DELETE", - url: `http://backend/instances/default/private/products/${id}`, -}); - -//////////////////// -// RESERVES -//////////////////// - -export const API_CREATE_RESERVE: Query< - MerchantBackend.Rewards.ReserveCreateRequest, - MerchantBackend.Rewards.ReserveCreateConfirmation -> = { - method: "POST", - url: "http://backend/instances/default/private/reserves", -}; -export const API_LIST_RESERVES: Query< - unknown, - MerchantBackend.Rewards.RewardReserveStatus -> = { - method: "GET", - url: "http://backend/instances/default/private/reserves", -}; - -export const API_GET_RESERVE_BY_ID = ( - pub: string, -): Query<unknown, MerchantBackend.Rewards.ReserveDetail> => ({ - method: "GET", - url: `http://backend/instances/default/private/reserves/${pub}`, -}); - -export const API_GET_REWARD_BY_ID = ( - pub: string, -): Query<unknown, MerchantBackend.Rewards.RewardDetails> => ({ - method: "GET", - url: `http://backend/instances/default/private/rewards/${pub}`, -}); - -export const API_AUTHORIZE_REWARD_FOR_RESERVE = ( - pub: string, -): Query< - MerchantBackend.Rewards.RewardCreateRequest, - MerchantBackend.Rewards.RewardCreateConfirmation -> => ({ - method: "POST", - url: `http://backend/instances/default/private/reserves/${pub}/authorize-reward`, -}); - -export const API_AUTHORIZE_REWARD: Query< - MerchantBackend.Rewards.RewardCreateRequest, - MerchantBackend.Rewards.RewardCreateConfirmation -> = { - method: "POST", - url: `http://backend/instances/default/private/rewards`, -}; - -export const API_DELETE_RESERVE = (id: string): Query<unknown, unknown> => ({ - method: "DELETE", - url: `http://backend/instances/default/private/reserves/${id}`, -}); - -//////////////////// -// INSTANCE ADMIN -//////////////////// - -export const API_CREATE_INSTANCE: Query< - MerchantBackend.Instances.InstanceConfigurationMessage, - unknown -> = { - method: "POST", - url: "http://backend/management/instances", -}; - -export const API_GET_INSTANCE_BY_ID = ( - id: string, -): Query<unknown, MerchantBackend.Instances.QueryInstancesResponse> => ({ - method: "GET", - url: `http://backend/management/instances/${id}`, -}); - -export const API_GET_INSTANCE_KYC_BY_ID = ( - id: string, -): Query<unknown, MerchantBackend.KYC.AccountKycRedirects> => ({ - method: "GET", - url: `http://backend/management/instances/${id}/kyc`, -}); - -export const API_LIST_INSTANCES: Query< - unknown, - MerchantBackend.Instances.InstancesResponse -> = { - method: "GET", - url: "http://backend/management/instances", -}; - -export const API_UPDATE_INSTANCE_BY_ID = ( - id: string, -): Query< - MerchantBackend.Instances.InstanceReconfigurationMessage, - unknown -> => ({ - method: "PATCH", - url: `http://backend/management/instances/${id}`, -}); - -export const API_UPDATE_INSTANCE_AUTH_BY_ID = ( - id: string, -): Query< - MerchantBackend.Instances.InstanceAuthConfigurationMessage, - unknown -> => ({ - method: "POST", - url: `http://backend/management/instances/${id}/auth`, -}); - -export const API_DELETE_INSTANCE = (id: string): Query<unknown, unknown> => ({ - method: "DELETE", - url: `http://backend/management/instances/${id}`, -}); - -//////////////////// -// AUTH -//////////////////// - -export const API_NEW_LOGIN: Query< - MerchantBackend.Instances.LoginTokenRequest, - unknown -> = ({ - method: "POST", - url: `http://backend/private/token`, -}); - -//////////////////// -// INSTANCE -//////////////////// - -export const API_GET_CURRENT_INSTANCE: Query< - unknown, - MerchantBackend.Instances.QueryInstancesResponse -> = { - method: "GET", - url: `http://backend/instances/default/private/`, -}; - -export const API_GET_CURRENT_INSTANCE_KYC: Query< - unknown, - MerchantBackend.KYC.AccountKycRedirects -> = { - method: "GET", - url: `http://backend/instances/default/private/kyc`, -}; - -export const API_UPDATE_CURRENT_INSTANCE: Query< - MerchantBackend.Instances.InstanceReconfigurationMessage, - unknown -> = { - method: "PATCH", - url: `http://backend/instances/default/private/`, -}; - -export const API_UPDATE_CURRENT_INSTANCE_AUTH: Query< - MerchantBackend.Instances.InstanceAuthConfigurationMessage, - unknown -> = { - method: "POST", - url: `http://backend/instances/default/private/auth`, -}; - -export const API_DELETE_CURRENT_INSTANCE: Query<unknown, unknown> = { - method: "DELETE", - url: `http://backend/instances/default/private`, -}; diff --git a/packages/auditor-backoffice-ui/src/hooks/webhooks.ts b/packages/auditor-backoffice-ui/src/hooks/webhooks.ts deleted file mode 100644 index ad6bf96e2..000000000 --- a/packages/auditor-backoffice-ui/src/hooks/webhooks.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - 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; - -export function useWebhookAPI(): WebhookAPI { - const mutateAll = useMatchMutate(); - const { request } = useBackendInstanceRequest(); - - const createWebhook = async ( - data: MerchantBackend.Webhooks.WebhookAddDetails, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`/private/webhooks`, { - method: "POST", - data, - }); - await mutateAll(/.*private\/webhooks.*/); - return res; - }; - - const updateWebhook = async ( - webhookId: string, - data: MerchantBackend.Webhooks.WebhookPatchDetails, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`/private/webhooks/${webhookId}`, { - method: "PATCH", - data, - }); - await mutateAll(/.*private\/webhooks.*/); - return res; - }; - - const deleteWebhook = async ( - webhookId: string, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`/private/webhooks/${webhookId}`, { - method: "DELETE", - }); - await mutateAll(/.*private\/webhooks.*/); - return res; - }; - - return { createWebhook, updateWebhook, deleteWebhook }; -} - -export interface WebhookAPI { - createWebhook: ( - data: MerchantBackend.Webhooks.WebhookAddDetails, - ) => Promise<HttpResponseOk<void>>; - updateWebhook: ( - id: string, - data: MerchantBackend.Webhooks.WebhookPatchDetails, - ) => Promise<HttpResponseOk<void>>; - deleteWebhook: (id: string) => Promise<HttpResponseOk<void>>; -} - -export interface InstanceWebhookFilter { - //FIXME: add filter to the webhook list - position?: string; -} - -export function useInstanceWebhooks( - args?: InstanceWebhookFilter, - updatePosition?: (id: string) => void, -): HttpResponsePaginated< - MerchantBackend.Webhooks.WebhookSummaryResponse, - MerchantBackend.ErrorDetail -> { - const { webhookFetcher } = useBackendInstanceRequest(); - - const [pageBefore, setPageBefore] = useState(1); - const [pageAfter, setPageAfter] = useState(1); - - const totalAfter = pageAfter * PAGE_SIZE; - const totalBefore = args?.position ? pageBefore * PAGE_SIZE : 0; - - const { - data: afterData, - error: afterError, - isValidating: loadingAfter, - } = useSWR< - HttpResponseOk<MerchantBackend.Webhooks.WebhookSummaryResponse>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/webhooks`, args?.position, -totalAfter], webhookFetcher); - - const [lastAfter, setLastAfter] = useState< - HttpResponse< - MerchantBackend.Webhooks.WebhookSummaryResponse, - MerchantBackend.ErrorDetail - > - >({ loading: true }); - useEffect(() => { - if (afterData) setLastAfter(afterData); - }, [afterData]); - - if (afterError) return afterError.cause; - - const isReachingEnd = - afterData && afterData.data.webhooks.length < totalAfter; - const isReachingStart = true; - - const pagination = { - isReachingEnd, - isReachingStart, - loadMore: () => { - if (!afterData || isReachingEnd) return; - if (afterData.data.webhooks.length < MAX_RESULT_SIZE) { - setPageAfter(pageAfter + 1); - } else { - const from = `${ - afterData.data.webhooks[afterData.data.webhooks.length - 1].webhook_id - }`; - if (from && updatePosition) updatePosition(from); - } - }, - loadMorePrev: () => { - return; - }, - }; - - const webhooks = !afterData ? [] : (afterData || lastAfter).data.webhooks; - - if (loadingAfter) return { loading: true, data: { webhooks } }; - if (afterData) { - return { ok: true, data: { webhooks }, ...pagination }; - } - return { loading: true }; -} - -export function useWebhookDetails( - webhookId: string, -): HttpResponse< - MerchantBackend.Webhooks.WebhookDetails, - MerchantBackend.ErrorDetail -> { - const { webhookFetcher } = useBackendInstanceRequest(); - - const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Webhooks.WebhookDetails>, - RequestError<MerchantBackend.ErrorDetail> - >([`/private/webhooks/${webhookId}`], webhookFetcher, { - 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 }; -} diff --git a/packages/auditor-backoffice-ui/src/i18n/poheader b/packages/auditor-backoffice-ui/src/i18n/poheader index 7ddcf49b8..b06d54a53 100644 --- a/packages/auditor-backoffice-ui/src/i18n/poheader +++ b/packages/auditor-backoffice-ui/src/i18n/poheader @@ -1,5 +1,5 @@ # This file is part of GNU Taler -# (C) 2021-2023 Taler Systems S.A. +# (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/i18n/strings-prelude b/packages/auditor-backoffice-ui/src/i18n/strings-prelude index 6c68662de..e8b8297be 100644 --- a/packages/auditor-backoffice-ui/src/i18n/strings-prelude +++ b/packages/auditor-backoffice-ui/src/i18n/strings-prelude @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/i18n/strings.ts b/packages/auditor-backoffice-ui/src/i18n/strings.ts index 65dc41358..d3fb99b29 100644 --- a/packages/auditor-backoffice-ui/src/i18n/strings.ts +++ b/packages/auditor-backoffice-ui/src/i18n/strings.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/i18n/taler-merchant-backoffice.pot b/packages/auditor-backoffice-ui/src/i18n/taler-merchant-backoffice.pot index 5ef56ca05..7324d3de6 100644 --- a/packages/auditor-backoffice-ui/src/i18n/taler-merchant-backoffice.pot +++ b/packages/auditor-backoffice-ui/src/i18n/taler-merchant-backoffice.pot @@ -1,5 +1,5 @@ # This file is part of GNU Taler -# (C) 2021-2023 Taler Systems S.A. +# (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/index.tsx b/packages/auditor-backoffice-ui/src/index.tsx index 7fdf7c1c3..fc956e8aa 100644 --- a/packages/auditor-backoffice-ui/src/index.tsx +++ b/packages/auditor-backoffice-ui/src/index.tsx @@ -21,4 +21,4 @@ import "./scss/main.scss"; const app = document.getElementById("app"); -render(<Application />, app as any); +render(<Application />, app as any);
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/Create.stories.tsx deleted file mode 100644 index 91b6b4b56..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/create/Create.stories.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - 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 { ConfigContextProvider } from "../../../context/config.js"; -import { CreatePage as TestedComponent } from "./CreatePage.js"; - -export default { - title: "Pages/Instance/Create", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - goBack: { action: "goBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => ( - <ConfigContextProvider - value={{ - currency: "ARS", - version: "1", - }} - > - <Component {...args} /> - </ConfigContextProvider> - ); - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, {}); -// export const Example = (a: any): VNode => <CreatePage {...a} />; -// Example.args = { -// isLoading: false -// } diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/CreatePage.tsx deleted file mode 100644 index d13b7e929..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/create/CreatePage.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/* - 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 { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; -import { MerchantBackend } from "../../../declaration.js"; -import { INSTANCE_ID_REGEX } from "../../../utils/constants.js"; -import { undefinedIfEmpty } from "../../../utils/table.js"; -import { SetTokenNewInstanceModal } from "../../../components/modal/index.js"; -import { Duration } from "@gnu-taler/taler-util"; - -export type Entity = Omit<Omit<MerchantBackend.Instances.InstanceConfigurationMessage, "default_pay_delay">, "default_wire_transfer_delay"> & { - auth_token?: string; - default_pay_delay: Duration, - default_wire_transfer_delay: Duration, -}; - -interface Props { - onCreate: (d: MerchantBackend.Instances.InstanceConfigurationMessage) => Promise<void>; - onBack?: () => void; - forceId?: string; -} - -function with_defaults(id?: string): Partial<Entity> { - return { - id, - // accounts: [], - user_type: "business", - use_stefan: true, - default_pay_delay: { d_ms: 2 * 60 * 60 * 1000 }, // two hours - default_wire_transfer_delay: { d_ms: 2 * 60 * 60 * 24 * 1000 }, // two days - }; -} - -export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { - const [value, valueHandler] = useState(with_defaults(forceId)); - const [isTokenSet, updateIsTokenSet] = useState<boolean>(false); - const [isTokenDialogActive, updateIsTokenDialogActive] = - useState<boolean>(false); - - const { i18n } = useTranslationContext(); - - const errors: FormErrors<Entity> = { - id: !value.id - ? i18n.str`required` - : !INSTANCE_ID_REGEX.test(value.id) - ? i18n.str`is not valid` - : undefined, - name: !value.name ? i18n.str`required` : undefined, - - user_type: !value.user_type - ? i18n.str`required` - : value.user_type !== "business" && value.user_type !== "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_pay_delay: !value.default_pay_delay - ? i18n.str`required` - : !!value.default_wire_transfer_delay && - value.default_wire_transfer_delay.d_ms !== "forever" && - value.default_pay_delay.d_ms !== "forever" && - value.default_pay_delay.d_ms > value.default_wire_transfer_delay.d_ms ? - i18n.str`pay delay can't be greater than wire transfer delay` : undefined, - default_wire_transfer_delay: !value.default_wire_transfer_delay - ? i18n.str`required` - : undefined, - address: undefinedIfEmpty({ - address_lines: - value.address?.address_lines && value.address?.address_lines.length > 7 - ? i18n.str`max 7 lines` - : undefined, - }), - jurisdiction: undefinedIfEmpty({ - address_lines: - value.address?.address_lines && value.address?.address_lines.length > 7 - ? i18n.str`max 7 lines` - : undefined, - }), - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submit = (): Promise<void> => { - // use conversion instead of this - const newToken = value.auth_token; - value.auth_token = undefined; - value.auth = newToken === null || newToken === undefined - ? { method: "external" } - : { method: "token", token: `secret-token:${newToken}` }; - if (!value.address) value.address = {}; - if (!value.jurisdiction) value.jurisdiction = {}; - // remove above use conversion - // schema.validateSync(value, { abortEarly: false }) - value.default_pay_delay = Duration.toTalerProtocolDuration(value.default_pay_delay!) as any - value.default_wire_transfer_delay = Duration.toTalerProtocolDuration(value.default_wire_transfer_delay!) as any - // delete value.default_pay_delay; - // delete value.default_wire_transfer_delay; - - return onCreate(value as any as MerchantBackend.Instances.InstanceConfigurationMessage); - }; - - function updateToken(token: string | null) { - valueHandler((old) => ({ - ...old, - auth_token: token === null ? undefined : token, - })); - } - - return ( - <div> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - {isTokenDialogActive && ( - <SetTokenNewInstanceModal - onCancel={() => { - updateIsTokenDialogActive(false); - updateIsTokenSet(false); - }} - onClear={() => { - updateToken(null); - updateIsTokenDialogActive(false); - updateIsTokenSet(true); - }} - onConfirm={(newToken) => { - updateToken(newToken); - updateIsTokenDialogActive(false); - updateIsTokenSet(true); - }} - /> - )} - </div> - <div class="column" /> - </div> - - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <FormProvider<Entity> - errors={errors} - object={value} - valueHandler={valueHandler} - > - <DefaultInstanceFormFields readonlyId={!!forceId} showId={true} /> - </FormProvider> - - <div class="level"> - <div class="level-item has-text-centered"> - <h1 class="title"> - <button - class={ - !isTokenSet - ? "button is-danger has-tooltip-bottom" - : !value.auth_token - ? "button has-tooltip-bottom" - : "button is-info has-tooltip-bottom" - } - data-tooltip={i18n.str`change authorization configuration`} - onClick={() => updateIsTokenDialogActive(true)} - > - <div class="icon is-centered"> - <i class="mdi mdi-lock-reset" /> - </div> - <span> - <i18n.Translate>Set access token</i18n.Translate> - </span> - </button> - </h1> - </div> - </div> - <div class="level"> - <div class="level-item has-text-centered"> - {!isTokenSet ? ( - <p class="is-size-6"> - <i18n.Translate> - Access token is not yet configured. This instance can't be - created. - </i18n.Translate> - </p> - ) : value.auth_token === undefined ? ( - <p class="is-size-6"> - <i18n.Translate> - No access token. Authorization must be handled externally. - </i18n.Translate> - </p> - ) : ( - <p class="is-size-6"> - <i18n.Translate> - Access token is set. Authorization is handled by the - merchant backend. - </i18n.Translate> - </p> - )} - </div> - </div> - <div class="buttons is-right mt-5"> - {onBack && ( - <button class="button" onClick={onBack}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - )} - <AsyncButton - onClick={submit} - disabled={hasErrors || !isTokenSet} - data-tooltip={ - hasErrors - ? i18n.str`Need to complete marked fields and choose authorization method` - : "confirm operation" - } - > - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </div> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx deleted file mode 100644 index c620c6482..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - 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 { CreatedSuccessfully } from "../../../components/notifications/CreatedSuccessfully.js"; -import { Entity } from "./index.js"; - -export function InstanceCreatedSuccessfully({ - entity, - onConfirm, -}: { - entity: Entity; - onConfirm: () => void; -}): VNode { - return ( - <CreatedSuccessfully onConfirm={onConfirm}> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">ID</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input class="input" readonly value={entity.id} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Business Name</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input class="input" readonly value={entity.name} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Access token</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - {entity.auth.method === "external" && "external"} - {entity.auth.method === "token" && ( - <input class="input" readonly value={entity.auth.token} /> - )} - </p> - </div> - </div> - </div> - </CreatedSuccessfully> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/index.tsx deleted file mode 100644 index 23f41ecff..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/create/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* - 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 { AccessToken, MerchantBackend } from "../../../declaration.js"; -import { useAdminAPI, useInstanceAPI } from "../../../hooks/instance.js"; -import { Notification } from "../../../utils/types.js"; -import { CreatePage } from "./CreatePage.js"; -import { useCredentialsChecker } from "../../../hooks/backend.js"; -import { useBackendContext } from "../../../context/backend.js"; - -interface Props { - onBack?: () => void; - onConfirm: () => void; - forceId?: string; -} -export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage; - -export default function Create({ onBack, onConfirm, forceId }: Props): VNode { - const { createInstance } = useAdminAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - const { requestNewLoginToken } = useCredentialsChecker() - const { url: backendURL, updateToken } = useBackendContext() - - return ( - <Fragment> - <NotificationCard notification={notif} /> - - <CreatePage - onBack={onBack} - forceId={forceId} - onCreate={async ( - d: MerchantBackend.Instances.InstanceConfigurationMessage, - ) => { - try { - await createInstance(d) - if (d.auth.token) { - const resp = await requestNewLoginToken(backendURL, d.auth.token as AccessToken) - if (resp.valid) { - const { token, expiration } = resp - updateToken({ token, expiration }); - } else { - updateToken(undefined) - } - } - onConfirm(); - } catch (ex) { - if (ex instanceof Error) { - setNotif({ - message: i18n.str`Failed to create instance`, - type: "ERROR", - description: ex.message, - }); - } else { - console.error(ex) - } - } - }} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/stories.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/stories.tsx deleted file mode 100644 index 0012f9b9b..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/create/stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - 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 { ConfigContextProvider } from "../../../context/config.js"; -import { CreatePage as TestedComponent } from "./CreatePage.js"; - -export default { - title: "Pages/Instance/Create", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - goBack: { action: "goBack" }, - }, -}; - -function createExample<Props>( - Internal: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const component = (args: any) => ( - <ConfigContextProvider - value={{ - currency: "TESTKUDOS", - version: "1", - }} - > - <Internal {...(props as any)} /> - </ConfigContextProvider> - ); - return { component, props }; -} - -export const Example = createExample(TestedComponent, {}); diff --git a/packages/auditor-backoffice-ui/src/paths/admin/index.stories.ts b/packages/auditor-backoffice-ui/src/paths/admin/index.stories.ts deleted file mode 100644 index fdae1a24d..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/index.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - 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/> - */ - -// export * as list from "./list/stories.js"; -export * as create from "./create/stories.js"; diff --git a/packages/auditor-backoffice-ui/src/paths/admin/list/TableActive.tsx b/packages/auditor-backoffice-ui/src/paths/admin/list/TableActive.tsx deleted file mode 100644 index 885a351d2..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/list/TableActive.tsx +++ /dev/null @@ -1,287 +0,0 @@ -/* - 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 { StateUpdater, useEffect, useState } from "preact/hooks"; -import { MerchantBackend } from "../../../declaration.js"; - -interface Props { - instances: MerchantBackend.Instances.Instance[]; - onUpdate: (id: string) => void; - onDelete: (id: MerchantBackend.Instances.Instance) => void; - onPurge: (id: MerchantBackend.Instances.Instance) => void; - onCreate: () => void; - selected?: boolean; - setInstanceName: (s: string) => void; -} - -export function CardTable({ - instances, - onCreate, - onUpdate, - onPurge, - setInstanceName, - onDelete, - selected, -}: Props): VNode { - const [actionQueue, actionQueueHandler] = useState<Actions[]>([]); - const [rowSelection, rowSelectionHandler] = useState<string[]>([]); - - useEffect(() => { - if ( - actionQueue.length > 0 && - !selected && - actionQueue[0].type == "DELETE" - ) { - onDelete(actionQueue[0].element); - actionQueueHandler(actionQueue.slice(1)); - } - }, [actionQueue, selected, onDelete]); - - useEffect(() => { - if ( - actionQueue.length > 0 && - !selected && - actionQueue[0].type == "UPDATE" - ) { - onUpdate(actionQueue[0].element.id); - actionQueueHandler(actionQueue.slice(1)); - } - }, [actionQueue, selected, onUpdate]); - - 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-desktop-mac" /> - </span> - <i18n.Translate>Instances</i18n.Translate> - </p> - - <div class="card-header-icon" aria-label="more options"> - <button - class={rowSelection.length > 0 ? "button is-danger" : "is-hidden"} - type="button" - onClick={(): void => - actionQueueHandler( - buildActions(instances, rowSelection, "DELETE"), - ) - } - > - <i18n.Translate>Delete</i18n.Translate> - </button> - </div> - <div class="card-header-icon" aria-label="more options"> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`add new instance`} - > - <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"> - {instances.length > 0 ? ( - <Table - instances={instances} - onPurge={onPurge} - onUpdate={onUpdate} - setInstanceName={setInstanceName} - onDelete={onDelete} - rowSelection={rowSelection} - rowSelectionHandler={rowSelectionHandler} - /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - ); -} -interface TableProps { - rowSelection: string[]; - instances: MerchantBackend.Instances.Instance[]; - onUpdate: (id: string) => void; - onDelete: (id: MerchantBackend.Instances.Instance) => void; - onPurge: (id: MerchantBackend.Instances.Instance) => void; - rowSelectionHandler: StateUpdater<string[]>; - setInstanceName: (s: string) => 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({ - rowSelection, - rowSelectionHandler, - setInstanceName, - instances, - onUpdate, - onDelete, - onPurge, -}: TableProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="table-container"> - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th class="is-checkbox-cell"> - <label class="b-checkbox checkbox"> - <input - type="checkbox" - checked={rowSelection.length === instances.length} - onClick={(): void => - rowSelectionHandler( - rowSelection.length === instances.length - ? [] - : instances.map((i) => i.id), - ) - } - /> - <span class="check" /> - </label> - </th> - <th> - <i18n.Translate>ID</i18n.Translate> - </th> - <th> - <i18n.Translate>Name</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - return ( - <tr key={i.id}> - <td class="is-checkbox-cell"> - <label class="b-checkbox checkbox"> - <input - type="checkbox" - checked={rowSelection.indexOf(i.id) != -1} - onClick={(): void => - rowSelectionHandler(toggleSelected(i.id)) - } - /> - <span class="check" /> - </label> - </td> - <td> - <a - href={`#/orders?instance=${i.id}`} - onClick={(e) => { - setInstanceName(i.id); - }} - > - {i.id} - </a> - </td> - <td>{i.name}</td> - <td class="is-actions-cell right-sticky"> - <div class="buttons is-right"> - <button - class="button is-small is-success jb-modal" - type="button" - onClick={(): void => onUpdate(i.id)} - > - <i18n.Translate>Edit</i18n.Translate> - </button> - {!i.deleted && ( - <button - class="button is-small is-danger jb-modal is-outlined" - type="button" - onClick={(): void => onDelete(i)} - > - <i18n.Translate>Delete</i18n.Translate> - </button> - )} - {i.deleted && ( - <button - class="button is-small is-danger jb-modal" - type="button" - onClick={(): void => onPurge(i)} - > - <i18n.Translate>Purge</i18n.Translate> - </button> - )} - </div> - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - ); -} - -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 instances yet, add more pressing the + sign - </i18n.Translate> - </p> - </div> - ); -} - -interface Actions { - element: MerchantBackend.Instances.Instance; - type: "DELETE" | "UPDATE"; -} - -function notEmpty<TValue>(value: TValue | null | undefined): value is TValue { - return value !== null && value !== undefined; -} - -function buildActions( - instances: MerchantBackend.Instances.Instance[], - selected: string[], - action: "DELETE", -): Actions[] { - return selected - .map((id) => instances.find((i) => i.id === id)) - .filter(notEmpty) - .map((id) => ({ element: id, type: action })); -} diff --git a/packages/auditor-backoffice-ui/src/paths/admin/list/View.stories.tsx b/packages/auditor-backoffice-ui/src/paths/admin/list/View.stories.tsx deleted file mode 100644 index e0f5d5430..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/list/View.stories.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - 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 } from "preact"; -import { View } from "./View.js"; - -export default { - title: "Pages/Instance/List", - component: View, - argTypes: { - onSelect: { action: "onSelect" }, - }, -}; - -export const Empty = (a: any) => <View {...a} />; -Empty.args = { - instances: [], -}; - -export const WithDefaultInstance = (a: any) => <View {...a} />; -WithDefaultInstance.args = { - instances: [ - { - id: "default", - name: "the default instance", - merchant_pub: "abcdef", - payment_targets: [], - }, - ], -}; - -export const WithFiveInstance = (a: any) => <View {...a} />; -WithFiveInstance.args = { - instances: [ - { - id: "first", - name: "the first instance", - merchant_pub: "abcdefgh", - payment_targets: ["asd"], - }, - { - id: "second", - name: "the second instance", - merchant_pub: "zxczxcz", - payment_targets: ["asd"], - }, - { - id: "third", - name: "the third instance", - merchant_pub: "QWEQWEWQE", - payment_targets: ["asd"], - }, - { - id: "other", - name: "the other instance", - merchant_pub: "FHJHGJGHJ", - payment_targets: ["asd"], - }, - { - id: "another", - name: "the another instance", - merchant_pub: "abcd3423423efgh", - payment_targets: ["asd"], - }, - { - id: "last", - name: "last instance", - merchant_pub: "zxcvvbnm", - payment_targets: ["pay-to", "asd"], - }, - ], -}; diff --git a/packages/auditor-backoffice-ui/src/paths/admin/list/View.tsx b/packages/auditor-backoffice-ui/src/paths/admin/list/View.tsx deleted file mode 100644 index b59112338..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/list/View.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - 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 { MerchantBackend } from "../../../declaration.js"; -import { CardTable as CardTableActive } from "./TableActive.js"; - -interface Props { - instances: MerchantBackend.Instances.Instance[]; - onCreate: () => void; - onUpdate: (id: string) => void; - onDelete: (id: MerchantBackend.Instances.Instance) => void; - onPurge: (id: MerchantBackend.Instances.Instance) => void; - selected?: boolean; - setInstanceName: (s: string) => void; -} - -export function View({ - instances, - onCreate, - onDelete, - onPurge, - onUpdate, - setInstanceName, - selected, -}: Props): VNode { - const [show, setShow] = useState<"active" | "deleted" | null>("active"); - const showIsActive = show === "active" ? "is-active" : ""; - const showIsDeleted = show === "deleted" ? "is-active" : ""; - const showAll = show === null ? "is-active" : ""; - const { i18n } = useTranslationContext(); - - const showingInstances = showIsDeleted - ? instances.filter((i) => i.deleted) - : showIsActive - ? instances.filter((i) => !i.deleted) - : instances; - - return ( - <section class="section is-main-section"> - <div class="columns"> - <div class="column is-two-thirds"> - <div class="tabs" style={{ overflow: "inherit" }}> - <ul> - <li class={showIsActive}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Only show active instances`} - > - <a onClick={() => setShow("active")}> - <i18n.Translate>Active</i18n.Translate> - </a> - </div> - </li> - <li class={showIsDeleted}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Only show deleted instances`} - > - <a onClick={() => setShow("deleted")}> - <i18n.Translate>Deleted</i18n.Translate> - </a> - </div> - </li> - <li class={showAll}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Show all instances`} - > - <a onClick={() => setShow(null)}> - <i18n.Translate>All</i18n.Translate> - </a> - </div> - </li> - </ul> - </div> - </div> - </div> - <CardTableActive - instances={showingInstances} - onDelete={onDelete} - onPurge={onPurge} - setInstanceName={setInstanceName} - onUpdate={onUpdate} - selected={selected} - onCreate={onCreate} - /> - </section> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/admin/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/admin/list/index.tsx deleted file mode 100644 index 2f839291b..000000000 --- a/packages/auditor-backoffice-ui/src/paths/admin/list/index.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { Loading } from "../../../components/exception/loading.js"; -import { NotificationCard } from "../../../components/menu/index.js"; -import { DeleteModal, PurgeModal } from "../../../components/modal/index.js"; -import { MerchantBackend } from "../../../declaration.js"; -import { useAdminAPI, useBackendInstances } from "../../../hooks/instance.js"; -import { Notification } from "../../../utils/types.js"; -import { View } from "./View.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -interface Props { - onCreate: () => void; - onUpdate: (id: string) => void; - instances: MerchantBackend.Instances.Instance[]; - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - setInstanceName: (s: string) => void; -} - -export default function Instances({ - onUnauthorized, - onLoadError, - onNotFound, - onCreate, - onUpdate, - setInstanceName, -}: Props): VNode { - const result = useBackendInstances(); - const [deleting, setDeleting] = - useState<MerchantBackend.Instances.Instance | null>(null); - const [purging, setPurging] = - useState<MerchantBackend.Instances.Instance | null>(null); - const { deleteInstance, purgeInstance } = useAdminAPI(); - 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} /> - <View - instances={result.data.instances} - onDelete={setDeleting} - onCreate={onCreate} - onPurge={setPurging} - onUpdate={onUpdate} - setInstanceName={setInstanceName} - selected={!!deleting} - /> - {deleting && ( - <DeleteModal - element={deleting} - onCancel={() => setDeleting(null)} - onConfirm={async (): Promise<void> => { - try { - await deleteInstance(deleting.id); - // pushNotification({ message: 'delete_success', type: 'SUCCESS' }) - setNotif({ - message: i18n.str`Instance "${deleting.name}" (ID: ${deleting.id}) has been deleted`, - type: "SUCCESS", - }); - } catch (error) { - setNotif({ - message: i18n.str`Failed to delete instance`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - // pushNotification({ message: 'delete_error', type: 'ERROR' }) - } - setDeleting(null); - }} - /> - )} - {purging && ( - <PurgeModal - element={purging} - onCancel={() => setPurging(null)} - onConfirm={async (): Promise<void> => { - try { - await purgeInstance(purging.id); - setNotif({ - message: i18n.str`Instance '${purging.name}' (ID: ${purging.id}) has been disabled`, - type: "SUCCESS", - }); - } catch (error) { - setNotif({ - message: i18n.str`Failed to purge instance`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - } - setPurging(null); - }} - /> - )} - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/default/Table.tsx b/packages/auditor-backoffice-ui/src/paths/default/Table.tsx new file mode 100644 index 000000000..9bb75907d --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/default/Table.tsx @@ -0,0 +1,155 @@ +/* + 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 { ComponentChildren, Fragment, h, VNode } from "preact"; +import { StateUpdater, useState } from "preact/hooks"; +import { useEntityContext, useEntityDataContext } from "../../context/entity.js"; + +interface Props { + onSuppress: (id: any) => void; +} + +export function CardTable({onSuppress}: Props): any { + + const data = useEntityDataContext(); + const [rowSelection, rowSelectionHandler] = useState<string | undefined>( + undefined, + ); + const { i18n } = useTranslationContext(); + const { title, endpoint, entity } = useEntityContext(); + + return ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-shopping" /> + </span> + <i18n.Translate>{title}</i18n.Translate> + </p> + <div class="card-header-icon" aria-label="more options"> + </div> + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {(data.data[0][endpoint] !== undefined && data.data[0][endpoint].length != 0) ? ( + <Table + data={data.data[0][endpoint]} + onSuppress={onSuppress} + /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + ); +} + +interface TableProps { + data: any; + onSuppress: (id: any) => void; +} + +function Table({ + data, + onSuppress, + }: TableProps): VNode { + const { i18n } = useTranslationContext(); + const { entity } = useEntityContext(); + type Entity = typeof entity; + let count = 0; + + return ( + <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + {Object.keys(data[0]).map((i: Entity) => { + const paramName = i[0].toUpperCase() + i.replace("_", " ").slice(1, i.count); + return ( + <Fragment key={count.toString() + i}> + <th> + <i18n.Translate>{paramName}</i18n.Translate> + </th> + </Fragment>); + })} + </tr> + </thead> + <tbody> + {data.map((key: Entity, value: string) => { + return ( + <tr> + {Object.keys(data[0]).map((i: Entity) => { + return ( + <Fragment> + <td> + {(key[i] == false) ? "false" : key[i]} + </td> + </Fragment> + ); + })} + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <span + class="has-tooltip-bottom" + data-tooltip={i18n.str`suppress`} + > + <button + class="button is-small is-success " + type="button" + onClick={(): void => onSuppress(key["row_id"])} + > + {<i18n.Translate>Suppress</i18n.Translate>} + </button> + </span> + </div> + </td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + ); +} + +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-happy mdi-48px" /> + </span> + </p> + <p> + <i18n.Translate> + There are no entries yet + </i18n.Translate> + </p> + </div> + ); +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/paths/default/index.tsx b/packages/auditor-backoffice-ui/src/paths/default/index.tsx new file mode 100644 index 000000000..1b7758190 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/default/index.tsx @@ -0,0 +1,130 @@ +/* + 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 Nic Eigel + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + ErrorType, + HttpError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../components/exception/loading.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { AuditorBackend, WithId } from "../../declaration.js"; +import { Notification } from "../../utils/types.js"; +import { CardTable } from "./Table.js"; +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { EntityDataContextProvider, useEntityContext } from "../../context/entity.js"; +import { getEntityList, useEntityAPI } from "../../hooks/entity.js"; +import { useMemo } from "preact/hooks"; +import { ConfirmModal, DeleteModal } from "../../components/modal/index.js"; +import { route } from "preact-router"; +import { Paths } from "../../InstanceRoutes.js"; + + +interface Props { + onNotFound: () => VNode; + onLoadError: (e: HttpError<AuditorBackend.ErrorDetail>) => VNode; +} + +export default function DefaultList({ + onLoadError, + onNotFound, + }: Props): VNode { + const { endpoint, entity } = useEntityContext(); + const result = getEntityList({ endpoint, entity }); + const { updateEntity } = useEntityAPI(); + const [suppressing, setSuppressing] = + useState<typeof entity & WithId | null>(null); + 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 onNotFound(); + return onLoadError(result); + } + + let data = result.data; + const value = useMemo( + () => ({ data }), + [data], + ); + + function onReturn(): void { + route(Paths.detail_view); + } + + return ( + + <section class="section is-main-section"> + <button + class="button is-fullwidth" + onClick={onReturn} + >Back + </button><br /> + + <NotificationCard notification={notif} /> + + <EntityDataContextProvider value={value}> + <CardTable + onSuppress={(e: typeof entity & WithId) => + setSuppressing(e) + } + /> + </EntityDataContextProvider> + + {suppressing && ( + <ConfirmModal + label={`Suppress row`} + description={`Suppress the row`} + danger + active + onCancel={() => setSuppressing(null)} + onConfirm={async (): Promise<void> => { + try { + await updateEntity(suppressing); + setNotif({ + message: i18n.str`Entity row with id: ${suppressing} has been suppressed`, + type: "SUCCESS", + }); + } catch (error) { + setNotif({ + message: i18n.str`Failed to suppress row`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + } + setSuppressing(null); + }} + > + <p class="warning"> + Suppressing a row <b>cannot be undone</b> in this GUI. + </p> + </ConfirmModal> + )} + </section> + ); +} diff --git a/packages/auditor-backoffice-ui/src/paths/details/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/details/ListPage.tsx new file mode 100644 index 000000000..60ae7b578 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/details/ListPage.tsx @@ -0,0 +1,346 @@ +/* + 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, Fragment } from "preact"; +import { route, Route } from "preact-router"; +import { Paths, Redirect } from "../../InstanceRoutes.js"; +import { AuditorBackend } from "../../declaration.js"; + +export interface ListPageProps { + onShowAll: () => void; + onShowNotPaid: () => void; + onShowPaid: () => void; + onShowRefunded: () => void; + onShowNotWired: () => void; + onShowWired: () => void; + onCopyURL: (id: string) => void; + isAllActive: string; + isPaidActive: string; + isNotPaidActive: string; + isRefundedActive: string; + isNotWiredActive: string; + isWiredActive: string; + + jumpToDate?: Date; + onSelectDate: (date?: Date) => void; + + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; + + onCreate: () => void; +} + +export function ListPage(): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + + <div class="columns"> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.amount_arithmethic_inconsistency_list) } + value={"Amount arithmetic inconsistencies"} + >Amount arithmetic inconsistencies + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.bad_sig_losses_list) } + value={"Bad signature losses"} + >Bad signature losses + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.closure_lag_list) } + >Closure Lags + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.coin_inconsistency_list) } + >Coin inconsistencies + </button> + </div> + </div> + </div> + </div> + + <div class="columns"> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.denomination_key_validity_withdraw_inconsistency_list) } + >Denominations key validity + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.denomination_without_sig_list) } + >Denominations without signature + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.denomination_pending_list) } + >Denominations pending + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.deposit_confirmation_list) } + >Deposit confirmations + </button> + </div> + </div> + </div> + </div> + + <div class="columns"> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.emergency_list) } + >Emergencies + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.emergency_by_count_list) } + >Emergencies by count + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.fee_time_inconsistency_list) } + >Fee time inconsistencies + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.misattribution_in_inconsistency_list) } + >Misattribution in inconsistencies + </button> + </div> + </div> + </div> + </div> + + <div class="columns"> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.purse_not_closed_inconsistency_list) } + >Purses not closed + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.purse_list) } + >Purses + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.refresh_hanging_list) } + >Refreshes hanging + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.reserve_balance_insufficient_inconsistency_list) } + >Reserve balances insufficient + </button> + </div> + </div> + </div> + </div> + + <div class="columns"> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.reserve_balance_summary_wrong_inconsistency_list) } + >Reserve balances summary wrong + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.reserve_in_inconsistency_list) } + >Reserves in + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.reserve_not_closed_inconsistency_list) } + >Reserves not closed + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.reserves_list) } + >Reserves + </button> + </div> + </div> + </div> + </div> + + <div class="columns"> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.row_inconsistency_list) } + >Row inconsistencies + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.row_minor_inconsistency_list) } + >Row minor inconsistencies + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.wire_format_inconsistency_list) } + >Wire format inconsistencies + </button> + </div> + </div> + </div> + <div class="column"> + <div class="card"> + <div class="card-body"> + <button + class="button is-fullwidth" + onClick={(e) => route(Paths.wire_out_inconsistency_list) } + >Wire out inconsistencies + </button> + </div> + </div> + </div> + </div> + + </Fragment> + ); +} diff --git a/packages/auditor-backoffice-ui/src/context/instance.ts b/packages/auditor-backoffice-ui/src/paths/details/index.tsx index 5800ade7e..f99dae7e5 100644 --- a/packages/auditor-backoffice-ui/src/context/instance.ts +++ b/packages/auditor-backoffice-ui/src/paths/details/index.tsx @@ -16,21 +16,24 @@ /** * + * @author Nic Eigel * @author Sebastian Javier Marchano (sebasjm) */ -import { createContext } from "preact"; -import { useContext } from "preact/hooks"; -import { LoginToken } from "../declaration.js"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { NotificationCard } from "../../components/menu/index.js"; +import { Notification } from "../../utils/types.js"; +import { ListPage } from "./ListPage.js"; -interface Type { - id: string; - token?: LoginToken; - admin?: boolean; - changeToken: (t?: LoginToken) => void; -} +export default function DetailsDashboard(): VNode { -const Context = createContext<Type>({} as any); + const [notif, setNotif] = useState<Notification | undefined>(undefined); -export const InstanceContextProvider = Context.Provider; -export const useInstanceContext = (): Type => useContext(Context); + return ( + <section class="section is-main-section"> + <NotificationCard notification={notif} /> + <ListPage /> + </section> + ); +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/paths/finance/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/finance/ListPage.tsx new file mode 100644 index 000000000..88ca6bcfd --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/finance/ListPage.tsx @@ -0,0 +1,214 @@ +/* + 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 Nic Eigel + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode, Fragment } from "preact"; + +export function ListPage(data: any): VNode { + const { i18n } = useTranslationContext(); + + let balances = data.data.data[0][4].data.balances; + let coinBalances = [ + "Total recoup loss", + "Coin refund fee revenue", + "Coin deposit fee revenue", + "Coin melt fee revenue", + "Coin irregular loss", + "Coins reported emergency risk by amount", + "Coins emergencies loss by count", + "Coins emergencies loss", + "Coins total arithmetic delta minus", + "Coins total arithmetic delta plus", + "Total escrowed", + "Total refresh hanging", + ]; + let reserveBalances = [ + "Total balance summary delta minus", + "Total balance reserve not closed", + "Reserves total arithmetic delta minus", + "Reserves total arithmetic delta plus", + "Reserves total bad signature loss", + "Reserves history fee revenue", + "Reserves open fee revenue", + ]; + let i = 0; + + return ( + <Fragment> + <div class="columns"> + <div class="column is-half"> + <div class="columns"> + <div class="column"> + <div class="card"> + <div class="card-content"> + <table class="table is-striped is-fullwidth is-dark"> + <tbody> + <tr> + <th>Finding</th> + <td class="has-text-right"><b>Count</b></td> + <td class="has-text-right"><b>Gain/Loss</b></td> + </tr> + { + data["data"]["data"][0].map((x: any) => { + const key = Object.keys(x.data)[0]; + let value = Object.values(x.data)[0]; + const paramName = key[0].toUpperCase() + key.split("_").join(" ").split("-").join(" ").slice(1, key.length); + if (key == "balances") { + //TODO fix + let gains = 0; + if (value == null) + value = 0; + else + value = Object.keys(value).length; + + return ( + <tr class="is-link"> + <td>{paramName}</td> + <td class="has-text-right"><p + class={value == 0 ? "text-success" : "text-danger"}>{String(value)}</p></td> + <td class="has-text-right"><p + class={gains == 0 ? "text-success" : "text-danger"}>{String(gains)}</p></td> + </tr> + ); + } else { + <tr class="is-link"> + <td>{paramName}</td> + <td class="has-text-right"><p + class={value == 0 ? "text-success" : "text-danger"}>{String(value)}</p></td> + <td class="has-text-right"><p>{ + //TODO + }</p></td> + </tr>; + } + }) + } + </tbody> + </table> + </div> + </div> + <div class="card"> + <div class="card-content"> + <table class="table is-striped is-fullwidth is-dark"> + <tbody> + <tr> + <th>Summary</th> + <td class="has-text-right"><b>Value</b></td> + </tr> + <tr> + <td>Total gain/loss</td> + <td class="has-text-right">{ + //TODO fix + }</td> + </tr> + <tr> + <td>Pending gain/loss</td> + <td class="has-text-right">{ + //TODO fix + }</td> + </tr> + <tr> + <td>Transaction count</td> + <td class="has-text-right">{ + //TODO fix + }</td> + </tr> + <tr> + <td>Transactions pending</td> + <td class="has-text-right">{ + //TODO fix + }</td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + <div class="column is-half"> + <div class="card"> + <div class="card-content"> + <p class="has-text-weight-bold">Helper coin</p> + <table class="table is-striped is-fullwidth is-dark"> + <tbody> + <tr> + <th>Balance</th> + <td><b>Value</b></td> + </tr> + { + balances.map((x: any) => { + let key = x.balance_key; + let balanceName = key[0].toUpperCase() + key.split("_").join(" ").split("-").join(" ").slice(1, key.length); + + if(coinBalances.includes(balanceName)) + { + let value = balances[i].balance_value.replace(":", " "); + i=i+1; + return ( + <tr class="is-link"> + <td>{balanceName}</td> + <td><p>{value}</p></td> + </tr> + ); + } else { + return null; + } + }) + } + </tbody> + </table> + <p class="has-text-weight-bold">Helper reserve</p> + <table class="table is-striped is-fullwidth is-dark"> + <tbody> + <tr> + <th>Balance</th> + <td><b>Value</b></td> + </tr> + { + balances.map((x: any) => { + let key = x.balance_key; + let balanceName = key[0].toUpperCase() + key.split("_").join(" ").split("-").join(" ").slice(1, key.length); + + if(reserveBalances.includes(balanceName)) + { + let value = balances[i].balance_value.replace(":", " "); + i = i+1; + return ( + <tr class="is-link"> + <td>{balanceName}</td> + <td><p>{value}</p></td> + </tr> + ); + } else { + return null; + } + }) + } + </tbody> + </table> + </div> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/finance/index.tsx index 8e0f7647f..b0d07aa0f 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/index.tsx +++ b/packages/auditor-backoffice-ui/src/paths/finance/index.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,43 +16,41 @@ /** * + * @author Nic Eigel * @author Sebastian Javier Marchano (sebasjm) */ import { ErrorType, - HttpError, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { h, VNode } 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 { useProductAPI, useProductDetails } from "../../../../hooks/product.js"; -import { Notification } from "../../../../utils/types.js"; -import { UpdatePage } from "./UpdatePage.js"; +import { Loading } from "../../components/exception/loading.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { Notification } from "../../utils/types.js"; +import { ListPage } from "./ListPage.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { getKeyFiguresData } from "../../hooks/finance.js"; + -export type Entity = MerchantBackend.Products.ProductAddDetail; interface Props { - onBack?: () => void; - onConfirm: () => void; onUnauthorized: () => VNode; onNotFound: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - pid: string; + onSelect: (id: string) => void; + onCreate: () => void; } -export default function UpdateProduct({ - pid, - onConfirm, - onBack, - onUnauthorized, - onNotFound, - onLoadError, -}: Props): VNode { - const { updateProduct } = useProductAPI(); - const result = useProductDetails(pid); + +export default function FinanceDashboard({ + onUnauthorized, + // onLoadError, + onCreate, + onSelect, + onNotFound, + }: Props): VNode { + + const result = getKeyFiguresData(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); @@ -69,27 +67,14 @@ export default function UpdateProduct({ result.status === HttpStatusCode.NotFound ) return onNotFound(); - return onLoadError(result); + else + return onNotFound(); } return ( - <Fragment> + <section class="section is-main-section"> <NotificationCard notification={notif} /> - <UpdatePage - product={{ ...result.data, product_id: pid }} - onBack={onBack} - onUpdate={(data) => { - return updateProduct(pid, data) - .then(onConfirm) - .catch((error) => { - setNotif({ - message: i18n.str`could not create product`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </Fragment> + <ListPage data={result} /> + </section> ); -} +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx deleted file mode 100644 index 3336c53a4..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx deleted file mode 100644 index 6e4786a47..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx +++ /dev/null @@ -1,173 +0,0 @@ -/* - 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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/index.tsx deleted file mode 100644 index 7d33d25ce..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - 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, - }); - }); - }} - /> - </> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx deleted file mode 100644 index 6b4b63735..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx deleted file mode 100644 index 24da755b9..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - 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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/Table.tsx deleted file mode 100644 index 7d6db0782..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/Table.tsx +++ /dev/null @@ -1,385 +0,0 @@ -/* - 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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/index.tsx deleted file mode 100644 index 100241e22..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/index.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - 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 ListOtpDevices({ - 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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx deleted file mode 100644 index d6b1d65e0..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - 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/OtpDevices/Update", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx deleted file mode 100644 index 0d20879e8..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/* - 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 { MerchantBackend, WithId } from "../../../../declaration.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; -import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; -import { undefinedIfEmpty } from "../../../../utils/table.js"; - -type Entity = MerchantBackend.BankAccounts.BankAccountEntry - & WithId; - -const accountAuthType = ["unedit", "none", "basic"]; -interface Props { - onUpdate: (d: MerchantBackend.BankAccounts.AccountPatchDetails) => Promise<void>; - onBack?: () => void; - account: Entity; -} - - -export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - - const [state, setState] = useState<Partial<MerchantBackend.BankAccounts.AccountPatchDetails>>(account); - - const errors: FormErrors<MerchantBackend.BankAccounts.AccountPatchDetails> = { - credit_facade_url: !state.credit_facade_url ? i18n.str`required` : !isValidURL(state.credit_facade_url) ? i18n.str`invalid url` : undefined, - credit_facade_credentials: undefinedIfEmpty({ - - username: state.credit_facade_credentials?.type !== "basic" ? undefined - : !state.credit_facade_credentials.username ? i18n.str`required` : undefined, - - password: state.credit_facade_credentials?.type !== "basic" ? undefined - : !state.credit_facade_credentials.password ? i18n.str`required` : undefined, - - repeatPassword: state.credit_facade_credentials?.type !== "basic" ? undefined - : !(state.credit_facade_credentials as any).repeatPassword ? i18n.str`required` : - (state.credit_facade_credentials as any).repeatPassword !== state.credit_facade_credentials.password ? i18n.str`doesn't match` - : undefined, - }), - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submitForm = () => { - if (hasErrors) return Promise.reject(); - - const creds: typeof state.credit_facade_credentials = - state.credit_facade_credentials?.type === "basic" ? { - type: "basic", - password: state.credit_facade_credentials.password, - username: state.credit_facade_credentials.username, - } : state.credit_facade_credentials?.type === "none" ? { - type: "none" - } : undefined; - - return onUpdate({ - credit_facade_credentials: creds, - credit_facade_url: state.credit_facade_url, - }); - }; - - 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.substring(0, 8)}...</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} - > - <InputPaytoForm<Entity> - name="payto_uri" - label={i18n.str`Account`} - readonly - /> - <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"; - if (str === "basic") return "With authentication"; - return "Do not change" - }} - /> - {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="credit_facade_credentials.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> - </section> - </section> - </div> - ); -} - -function isValidURL(s: string): boolean { - try { - const u = new URL(s) - return true; - } catch (e) { - return false; - } -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/index.tsx deleted file mode 100644 index 44dee7651..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - 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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/Create.stories.tsx deleted file mode 100644 index 2fc0819bb..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/Create.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - 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/Product/Create", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, {}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatedSuccessfully.tsx deleted file mode 100644 index 573064aea..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatedSuccessfully.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - 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 { h, VNode } from "preact"; -import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { Entity } from "./index.js"; -import emptyImage from "../../assets/empty.png"; - -interface Props { - entity: Entity; - onConfirm: () => void; - onCreateAnother?: () => void; -} - -export function CreatedSuccessfully({ - entity, - onConfirm, - onCreateAnother, -}: Props): VNode { - return ( - <Template onConfirm={onConfirm} onCreateAnother={onCreateAnother}> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Image</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Description</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Price</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - </p> - </div> - </div> - </div> - </Template> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/index.tsx deleted file mode 100644 index 99599cfab..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - 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 { AuditorBackend } from "../../../../declaration.js"; -import { useDepositConfirmationAPI } from "../../../../hooks/deposit_confirmations.js"; -import { Notification } from "../../../../utils/types.js"; -import { CreatePage } from "./CreatePage.js"; - -export type Entity = AuditorBackend.DepositConfirmation.DepositConfirmationDetail; -interface Props { - onBack?: () => void; - onConfirm: () => void; -} -export default function CreateProduct({ onConfirm, onBack }: Props): VNode { - const { createDepositConfirmation } = useDepositConfirmationAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - - return ( - <Fragment> - <NotificationCard notification={notif} /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/List.stories.tsx deleted file mode 100644 index 41c297d5b..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/List.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - 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 { CardTable as TestedComponent } from "./Table.js"; - -export default { - title: "Pages/Product/List", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onSelect: { action: "onSelect" }, - onDelete: { action: "onDelete" }, - onUpdate: { action: "onUpdate" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/index.tsx deleted file mode 100644 index a99cfd2ef..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/index.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 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) - * @author Nic Eigel - */ - -import { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { Loading } from "../../../../components/exception/loading.js"; -import { NotificationCard } from "../../../../components/menu/index.js"; -import { AuditorBackend, WithId } from "../../../../declaration.js"; -import { - useDepositConfirmation, - useDepositConfirmationAPI, -} from "../../../../hooks/deposit_confirmations.js"; -import { Notification } from "../../../../utils/types.js"; -import { CardTable } from "./Table.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { ConfirmModal, DeleteModal } from "../../../../components/modal/index.js"; -import { JumpToElementById } from "../../../../components/form/JumpToElementById.js"; - -interface Props { - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onCreate: () => void; - onSelect: (id: string) => void; - onLoadError: (e: HttpError<AuditorBackend.ErrorDetail>) => VNode; -} -export default function DepositConfirmationList({ - onUnauthorized, - onLoadError, - onCreate, - onSelect, - onNotFound, -}: Props): VNode { - const result = useDepositConfirmation(); - const { deleteDepositConfirmation, updateDepositConfirmation, getDepositConfirmation } = useDepositConfirmationAPI(); - const [deleting, setDeleting] = - useState<AuditorBackend.DepositConfirmation.DepositConfirmationDetail & WithId | null>(null); - 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 ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> - - <JumpToElementById - testIfExist={getDepositConfirmation} - onSelect={onSelect} - description={i18n.str`jump to deposit_confirmation with the given serial ID`} - placeholder={i18n.str`serial id`} - /> - - {deleting && ( - <ConfirmModal - label={`Delete deposit-confirmation`} - description={`Delete the deposit-cofirmation "${deleting.serial_id}"`} - danger - active - onCancel={() => setDeleting(null)} - onConfirm={async (): Promise<void> => { - try { - await deleteDepositConfirmation(deleting.serial_id); - setNotif({ - message: i18n.str`Deposit-confirmation "${deleting.serial_id}" (ID: ${deleting.serial_id}) has been deleted`, - type: "SUCCESS", - }); - } catch (error) { - setNotif({ - message: i18n.str`Failed to delete deposit-confirmation`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - } - setDeleting(null); - }} - > - <p> - If you delete the deposit-confirmation (ID:{" "} - <b>{deleting.serial_id}</b>), the stock and related information will be lost - </p> - <p class="warning"> - Deleting a deposit-confirmation <b>cannot be undone</b>. - </p> - </ConfirmModal> - )} - </section> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/Update.stories.tsx deleted file mode 100644 index a85b13b8b..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/Update.stories.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - 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/Product/Update", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const WithManagedStock = createExample(TestedComponent, { - product: { - product_id: "20102-ASDAS-QWE", - description: "description1", - description_i18n: {} as any, - image: "", - price: "TESTKUDOS:10", - taxes: [], - total_lost: 10, - total_sold: 5, - total_stock: 15, - unit: "bar", - address: {}, - }, -}); - -export const WithInfiniteStock = createExample(TestedComponent, { - product: { - product_id: "20102-ASDAS-QWE", - description: "description1", - description_i18n: {} as any, - image: "", - price: "TESTKUDOS:10", - taxes: [], - total_lost: 10, - total_sold: 5, - total_stock: -1, - unit: "bar", - address: {}, - }, -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/UpdatePage.tsx deleted file mode 100644 index 97715171e..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/UpdatePage.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - 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 { AsyncButton } from "../../../../components/exception/AsyncButton.js"; -import { ProductForm } from "../../../../components/product/ProductForm.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useListener } from "../../../../hooks/listener.js"; - -type Entity = MerchantBackend.Products.ProductDetail & { product_id: string }; - -interface Props { - onUpdate: (d: Entity) => Promise<void>; - onBack?: () => void; - product: Entity; -} - -export function UpdatePage({ product, onUpdate, onBack }: Props): VNode { - const [submitForm, addFormSubmitter] = useListener<Entity | undefined>( - (result) => { - if (result) return onUpdate(result); - return Promise.resolve(); - }, - ); - - const { i18n } = useTranslationContext(); - - 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"> - <i18n.Translate>Product id:</i18n.Translate> - <b>{product.product_id}</b> - </span> - </div> - </div> - </div> - </div> - </section> - <hr /> - - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <ProductForm - initial={product} - onSubscribe={addFormSubmitter} - alreadyExist - /> - - <div class="buttons is-right mt-5"> - {onBack && ( - <button class="button" onClick={onBack}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - )} - <AsyncButton - onClick={submitForm} - data-tooltip={ - !submitForm - ? i18n.str`Need to complete marked fields` - : "confirm operation" - } - disabled={!submitForm} - > - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </div> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/details/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/details/DetailPage.tsx deleted file mode 100644 index 21dadb1e3..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/details/DetailPage.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - 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 { FormProvider } from "../../../components/form/FormProvider.js"; -import { Input } from "../../../components/form/Input.js"; -import { MerchantBackend } from "../../../declaration.js"; - -type Entity = MerchantBackend.Instances.InstanceReconfigurationMessage; -interface Props { - onUpdate: () => void; - onDelete: () => void; - selected: MerchantBackend.Instances.QueryInstancesResponse; -} - -function convert( - from: MerchantBackend.Instances.QueryInstancesResponse, -): Entity { - const defaults = { - default_wire_fee_amortization: 1, - use_stefan: true, - default_pay_delay: { d_us: 1000 * 60 * 60 * 1000 }, //one hour - default_wire_transfer_delay: { d_us: 1000 * 60 * 60 * 2 * 1000 }, //two hours - }; - return { ...defaults, ...from }; -} - -export function DetailPage({ selected }: Props): VNode { - const [value, valueHandler] = useState<Partial<Entity>>(convert(selected)); - - const { i18n } = useTranslationContext(); - - return ( - <div> - <section class="hero is-hero-bar"> - <div class="hero-body"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <h1 class="title">Here goes the instance description</h1> - </div> - </div> - <div class="level-right" style="display: none;"> - <div class="level-item" /> - </div> - </div> - </div> - </section> - - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-6"> - <FormProvider<Entity> object={value} valueHandler={valueHandler}> - <Input<Entity> name="name" readonly label={i18n.str`Name`} /> - </FormProvider> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/details/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/details/index.tsx deleted file mode 100644 index 9b393b818..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/details/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - 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 { ErrorType, HttpError } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { Loading } from "../../../components/exception/loading.js"; -import { DeleteModal } from "../../../components/modal/index.js"; -import { useInstanceContext } from "../../../context/instance.js"; -import { MerchantBackend } from "../../../declaration.js"; -import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; -import { DetailPage } from "./DetailPage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -interface Props { - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onUpdate: () => void; - onNotFound: () => VNode; - onDelete: () => void; -} - -export default function Detail({ - onUpdate, - onLoadError, - onUnauthorized, - onDelete, - onNotFound, -}: Props): VNode { - const { id } = useInstanceContext(); - const result = useInstanceDetails(); - const [deleting, setDeleting] = useState<boolean>(false); - - const { deleteInstance } = useInstanceAPI(); - - 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> - <DetailPage - selected={result.data} - onUpdate={onUpdate} - onDelete={() => setDeleting(true)} - /> - {deleting && ( - <DeleteModal - element={{ name: result.data.name, id }} - onCancel={() => setDeleting(false)} - onConfirm={async (): Promise<void> => { - try { - await deleteInstance(); - onDelete(); - } catch (error) { - //FIXME: show message error - } - setDeleting(false); - }} - /> - )} - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/details/stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/details/stories.tsx deleted file mode 100644 index 367fabce2..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/details/stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - 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 { ConfigContextProvider } from "../../../context/config.js"; -import { DetailPage as TestedComponent } from "./DetailPage.js"; - -export default { - title: "Pages/Instance/Detail", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Internal: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const component = (args: any) => ( - <ConfigContextProvider - value={{ - currency: "TESTKUDOS", - version: "1", - }} - > - <Internal {...(props as any)} /> - </ConfigContextProvider> - ); - return { component, props }; -} - -export const Example = createExample(TestedComponent, { - selected: { - name: "name", - auth: { method: "external" }, - address: {}, - user_type: "business", - jurisdiction: {}, - use_stefan: true, - default_pay_delay: { - d_us: 1000 * 1000, //one second - }, - default_wire_transfer_delay: { - d_us: 1000 * 1000, //one second - }, - merchant_pub: "ASDWQEKASJDKSADJ", - }, -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/index.stories.ts b/packages/auditor-backoffice-ui/src/paths/instance/index.stories.ts deleted file mode 100644 index 1d8c76ff9..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/index.stories.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - 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/> - */ - -export * as details from "./details/stories.js"; -export * as kycList from "./kyc/list/ListPage.stories.js"; -export * as reserve from "./reserves/create/CreatedSuccessfully.stories.js"; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx deleted file mode 100644 index d33f64ada..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - 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 { ListPage as TestedComponent } from "./ListPage.js"; -import * as tests from "@gnu-taler/web-util/testing"; -import { MerchantBackend } from "../../../../declaration.js"; - -export default { - title: "Pages/KYC/List", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; - -export const Example = tests.createExample(TestedComponent, { - status: { - timeout_kycs: [], - pending_kycs: [ - { - aml_status: 0, - exchange_url: "http://exchange.taler", - payto_uri: "payto://iban/de123123123", - kyc_url: "http://exchange.taler/kyc", - }, - { - aml_status: 1, - exchange_url: "http://exchange.taler", - payto_uri: "payto://iban/de123123123", - }, - { - aml_status: 2, - exchange_url: "http://exchange.taler", - payto_uri: "payto://iban/de123123123", - }, - ], - } as MerchantBackend.KYC.AccountKycRedirects, -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx deleted file mode 100644 index 338081886..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx +++ /dev/null @@ -1,208 +0,0 @@ -/* - 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 { MerchantBackend } from "../../../../declaration.js"; - -export interface Props { - status: MerchantBackend.KYC.AccountKycRedirects; -} - -export function ListPage({ status }: Props): VNode { - const { i18n } = useTranslationContext(); - - return ( - <section class="section is-main-section"> - <div class="card has-table"> - <header class="card-header"> - <p class="card-header-title"> - <span class="icon"> - <i class="mdi mdi-clock" /> - </span> - <i18n.Translate>Pending KYC verification</i18n.Translate> - </p> - - <div class="card-header-icon" aria-label="more options" /> - </header> - <div class="card-content"> - <div class="b-table has-pagination"> - <div class="table-wrapper has-mobile-cards"> - {status.pending_kycs.length > 0 ? ( - <PendingTable entries={status.pending_kycs} /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - - {status.timeout_kycs.length > 0 ? ( - <div class="card has-table"> - <header class="card-header"> - <p class="card-header-title"> - <span class="icon"> - <i class="mdi mdi-clock" /> - </span> - <i18n.Translate>Timed out</i18n.Translate> - </p> - - <div class="card-header-icon" aria-label="more options" /> - </header> - <div class="card-content"> - <div class="b-table has-pagination"> - <div class="table-wrapper has-mobile-cards"> - {status.timeout_kycs.length > 0 ? ( - <TimedOutTable entries={status.timeout_kycs} /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - ) : undefined} - </section> - ); -} -interface PendingTableProps { - entries: MerchantBackend.KYC.MerchantAccountKycRedirect[]; -} - -interface TimedOutTableProps { - entries: MerchantBackend.KYC.ExchangeKycTimeout[]; -} - -function PendingTable({ entries }: PendingTableProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="table-container"> - <table class="table is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>Exchange</i18n.Translate> - </th> - <th> - <i18n.Translate>Target account</i18n.Translate> - </th> - <th> - <i18n.Translate>Reason</i18n.Translate> - </th> - </tr> - </thead> - <tbody> - {entries.map((e, i) => { - if (e.kyc_url === undefined) { - // blocked by AML - return ( - <tr key={i}> - <td>{e.exchange_url}</td> - <td>{e.payto_uri}</td> - <td> - {e.aml_status === 1 ? ( - <i18n.Translate> - There is an anti-money laundering process pending to - complete. - </i18n.Translate> - ) : ( - <i18n.Translate> - The account is frozen due to the anti-money laundering - rules. Contact the exchange service provider for further - instructions. - </i18n.Translate> - )} - </td> - </tr> - ); - } else { - // blocked by KYC - return ( - <tr key={i}> - <td>{e.exchange_url}</td> - <td>{e.payto_uri}</td> - <td> - <a href={e.kyc_url} target="_black" rel="noreferrer"> - <i18n.Translate> - Pending KYC process, click here to complete - </i18n.Translate> - </a> - </td> - </tr> - ); - } - })} - </tbody> - </table> - </div> - ); -} - -function TimedOutTable({ entries }: TimedOutTableProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="table-container"> - <table class="table is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>Exchange</i18n.Translate> - </th> - <th> - <i18n.Translate>Code</i18n.Translate> - </th> - <th> - <i18n.Translate>Http Status</i18n.Translate> - </th> - </tr> - </thead> - <tbody> - {entries.map((e, i) => { - return ( - <tr key={i}> - <td>{e.exchange_url}</td> - <td>{e.exchange_code}</td> - <td>{e.exchange_http_status}</td> - </tr> - ); - })} - </tbody> - </table> - </div> - ); -} - -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-happy mdi-48px" /> - </span> - </p> - <p> - <i18n.Translate>No pending kyc verification!</i18n.Translate> - </p> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/index.tsx deleted file mode 100644 index 5b93ac169..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - 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 { ErrorType, HttpError } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; -import { Loading } from "../../../../components/exception/loading.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useInstanceKYCDetails } from "../../../../hooks/instance.js"; -import { ListPage } from "./ListPage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -interface Props { - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onNotFound: () => VNode; -} - -export default function ListKYC({ - onUnauthorized, - onLoadError, - onNotFound, -}: Props): VNode { - const result = useInstanceKYCDetails(); - 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); - } - - const status = result.data.type === "ok" ? undefined : result.data.status; - - if (!status) { - return <div>no kyc required</div>; - } - return <ListPage status={status} />; -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx deleted file mode 100644 index bd9f65718..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - 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/Order/Create", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - goBack: { action: "goBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, { - instanceConfig: { - default_pay_delay: { - d_us: 1000 * 1000 * 60 * 60, //one hour - }, - default_wire_transfer_delay: { - d_us: 1000 * 1000 * 60 * 60, //one hour - }, - use_stefan: true, - }, - instanceInventory: [ - { - id: "t-shirt-1", - description: "a m size t-shirt", - price: "TESTKUDOS:1", - total_stock: -1, - }, - { - id: "t-shirt-2", - price: "TESTKUDOS:1", - description: "a xl size t-shirt", - } as any, - { - id: "t-shirt-3", - price: "TESTKUDOS:1", - description: "a s size t-shirt", - } as any, - ], -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx deleted file mode 100644 index 62ceaa24b..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx +++ /dev/null @@ -1,705 +0,0 @@ -/* - 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 { AbsoluteTime, Amounts, Duration, TalerProtocolDuration } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { format, isFuture } from "date-fns"; -import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { - FormErrors, - FormProvider, -} from "../../../../components/form/FormProvider.js"; -import { Input } from "../../../../components/form/Input.js"; -import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputDate } from "../../../../components/form/InputDate.js"; -import { InputDuration } from "../../../../components/form/InputDuration.js"; -import { InputGroup } from "../../../../components/form/InputGroup.js"; -import { InputLocation } from "../../../../components/form/InputLocation.js"; -import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { InputToggle } from "../../../../components/form/InputToggle.js"; -import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js"; -import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js"; -import { ProductList } from "../../../../components/product/ProductList.js"; -import { useConfigContext } from "../../../../context/config.js"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { useSettings } from "../../../../hooks/useSettings.js"; -import { OrderCreateSchema as schema } from "../../../../schemas/index.js"; -import { rate } from "../../../../utils/amount.js"; -import { undefinedIfEmpty } from "../../../../utils/table.js"; - -interface Props { - onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; - onBack?: () => void; - instanceConfig: InstanceConfig; - instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[]; -} -interface InstanceConfig { - use_stefan: boolean; - default_pay_delay: TalerProtocolDuration; - default_wire_transfer_delay: TalerProtocolDuration; -} - -function with_defaults(config: InstanceConfig, currency: string): Partial<Entity> { - const defaultPayDeadline = Duration.fromTalerProtocolDuration(config.default_pay_delay); - const defaultWireDeadline = Duration.fromTalerProtocolDuration(config.default_wire_transfer_delay); - - return { - inventoryProducts: {}, - products: [], - pricing: {}, - payments: { - max_fee: undefined, - createToken: true, - pay_deadline: (defaultPayDeadline), - refund_deadline: (defaultPayDeadline), - wire_transfer_deadline: (defaultWireDeadline), - }, - shipping: {}, - extra: {}, - }; -} - -interface ProductAndQuantity { - product: MerchantBackend.Products.ProductDetail & WithId; - quantity: number; -} -export interface ProductMap { - [id: string]: ProductAndQuantity; -} - -interface Pricing { - products_price: string; - order_price: string; - summary: string; -} -interface Shipping { - delivery_date?: Date; - delivery_location?: MerchantBackend.Location; - fullfilment_url?: string; -} -interface Payments { - refund_deadline: Duration; - pay_deadline: Duration; - wire_transfer_deadline: Duration; - auto_refund_deadline: Duration; - max_fee?: string; - createToken: boolean; - minimum_age?: number; -} -interface Entity { - inventoryProducts: ProductMap; - products: MerchantBackend.Product[]; - pricing: Partial<Pricing>; - payments: Partial<Payments>; - shipping: Partial<Shipping>; - extra: Record<string, string>; -} - -const stringIsValidJSON = (value: string) => { - try { - JSON.parse(value.trim()); - return true; - } catch { - return false; - } -}; - -export function CreatePage({ - onCreate, - onBack, - instanceConfig, - instanceInventory, -}: Props): VNode { - const config = useConfigContext(); - const instance_default = with_defaults(instanceConfig, config.currency) - const [value, valueHandler] = useState(instance_default); - const zero = Amounts.zeroOfCurrency(config.currency); - const [settings, updateSettings] = useSettings() - const inventoryList = Object.values(value.inventoryProducts || {}); - const productList = Object.values(value.products || {}); - - const { i18n } = useTranslationContext(); - - const parsedPrice = !value.pricing?.order_price - ? undefined - : Amounts.parse(value.pricing.order_price); - - const errors: FormErrors<Entity> = { - pricing: undefinedIfEmpty({ - summary: !value.pricing?.summary ? i18n.str`required` : undefined, - order_price: !value.pricing?.order_price - ? i18n.str`required` - : !parsedPrice - ? i18n.str`not valid` - : Amounts.isZero(parsedPrice) - ? i18n.str`must be greater than 0` - : undefined, - }), - payments: undefinedIfEmpty({ - refund_deadline: !value.payments?.refund_deadline - ? undefined - : value.payments.pay_deadline && - Duration.cmp(value.payments.refund_deadline, value.payments.pay_deadline) === -1 - ? i18n.str`refund deadline cannot be before pay deadline` - : value.payments.wire_transfer_deadline && - Duration.cmp( - value.payments.wire_transfer_deadline, - value.payments.refund_deadline, - ) === -1 - ? i18n.str`wire transfer deadline cannot be before refund deadline` - : undefined, - pay_deadline: !value.payments?.pay_deadline - ? i18n.str`required` - : value.payments.wire_transfer_deadline && - Duration.cmp( - value.payments.wire_transfer_deadline, - value.payments.pay_deadline, - ) === -1 - ? i18n.str`wire transfer deadline cannot be before pay deadline` - : undefined, - wire_transfer_deadline: !value.payments?.wire_transfer_deadline - ? i18n.str`required` - : undefined, - auto_refund_deadline: !value.payments?.auto_refund_deadline - ? undefined - : !value.payments?.refund_deadline - ? i18n.str`should have a refund deadline` - : Duration.cmp( - value.payments.refund_deadline, - value.payments.auto_refund_deadline, - ) == -1 - ? i18n.str`auto refund cannot be after refund deadline` - : undefined, - - }), - shipping: undefinedIfEmpty({ - delivery_date: !value.shipping?.delivery_date - ? undefined - : !isFuture(value.shipping.delivery_date) - ? i18n.str`should be in the future` - : undefined, - }), - }; - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submit = (): void => { - const order = value as any; //schema.cast(value); - if (!value.payments) return; - if (!value.shipping) return; - - const request: MerchantBackend.Orders.PostOrderRequest = { - order: { - amount: order.pricing.order_price, - summary: order.pricing.summary, - products: productList, - extra: undefinedIfEmpty(value.extra), - pay_deadline: !value.payments.pay_deadline ? - i18n.str`required` : - AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.pay_deadline)) - ,// : undefined, - wire_transfer_deadline: value.payments.wire_transfer_deadline - ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.wire_transfer_deadline)) - : undefined, - refund_deadline: value.payments.refund_deadline - ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.refund_deadline)) - : undefined, - auto_refund: value.payments.auto_refund_deadline - ? Duration.toTalerProtocolDuration(value.payments.auto_refund_deadline) - : undefined, - max_fee: value.payments.max_fee as string, - - delivery_date: value.shipping.delivery_date - ? { t_s: value.shipping.delivery_date.getTime() / 1000 } - : undefined, - delivery_location: value.shipping.delivery_location, - fulfillment_url: value.shipping.fullfilment_url, - minimum_age: value.payments.minimum_age, - }, - inventory_products: inventoryList.map((p) => ({ - product_id: p.product.id, - quantity: p.quantity, - })), - create_token: value.payments.createToken, - }; - - onCreate(request); - }; - - const addProductToTheInventoryList = ( - product: MerchantBackend.Products.ProductDetail & WithId, - quantity: number, - ) => { - valueHandler((v) => { - const inventoryProducts = { ...v.inventoryProducts }; - inventoryProducts[product.id] = { product, quantity }; - return { ...v, inventoryProducts }; - }); - }; - - const removeProductFromTheInventoryList = (id: string) => { - valueHandler((v) => { - const inventoryProducts = { ...v.inventoryProducts }; - delete inventoryProducts[id]; - return { ...v, inventoryProducts }; - }); - }; - - const addNewProduct = async (product: MerchantBackend.Product) => { - return valueHandler((v) => { - const products = v.products ? [...v.products, product] : []; - return { ...v, products }; - }); - }; - - const removeFromNewProduct = (index: number) => { - valueHandler((v) => { - const products = v.products ? [...v.products] : []; - products.splice(index, 1); - return { ...v, products }; - }); - }; - - const [editingProduct, setEditingProduct] = useState< - MerchantBackend.Product | undefined - >(undefined); - - const totalPriceInventory = inventoryList.reduce((prev, cur) => { - const p = Amounts.parseOrThrow(cur.product.price); - return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount; - }, zero); - - const totalPriceProducts = productList.reduce((prev, cur) => { - if (!cur.price) return zero; - const p = Amounts.parseOrThrow(cur.price); - return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount; - }, zero); - - const hasProducts = inventoryList.length > 0 || productList.length > 0; - const totalPrice = Amounts.add(totalPriceInventory, totalPriceProducts); - - const totalAsString = Amounts.stringify(totalPrice.amount); - const allProducts = productList.concat(inventoryList.map(asProduct)); - - const [newField, setNewField] = useState("") - - useEffect(() => { - valueHandler((v) => { - return { - ...v, - pricing: { - ...v.pricing, - products_price: hasProducts ? totalAsString : undefined, - order_price: hasProducts ? totalAsString : undefined, - }, - }; - }); - }, [hasProducts, totalAsString]); - - const discountOrRise = rate( - parsedPrice ?? Amounts.zeroOfCurrency(config.currency), - totalPrice.amount, - ); - - const minAgeByProducts = allProducts.reduce( - (cur, prev) => - !prev.minimum_age || cur > prev.minimum_age ? cur : prev.minimum_age, - 0, - ); - - // if there is no default pay deadline - const noDefault_payDeadline = !instance_default.payments || !instance_default.payments.pay_deadline - // and there is no default wire deadline - const noDefault_wireDeadline = !instance_default.payments || !instance_default.payments.wire_transfer_deadline - // user required to set the taler options - const requiresSomeTalerOptions = noDefault_payDeadline || noDefault_wireDeadline - - - return ( - <div> - - <section class="section is-main-section"> - <div class="tabs is-toggle is-fullwidth is-small"> - <ul> - <li class={!settings.advanceOrderMode ? "is-active" : ""} onClick={() => { - updateSettings({ - ...settings, - advanceOrderMode: false - }) - }}> - <a > - <span><i18n.Translate>Simple</i18n.Translate></span> - </a> - </li> - <li class={settings.advanceOrderMode ? "is-active" : ""} onClick={() => { - updateSettings({ - ...settings, - advanceOrderMode: true - }) - }}> - <a > - <span><i18n.Translate>Advanced</i18n.Translate></span> - </a> - </li> - </ul> - </div> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - {/* // FIXME: translating plural singular */} - <InputGroup - name="inventory_products" - label={i18n.str`Manage products in order`} - alternative={ - allProducts.length > 0 && ( - <p> - {allProducts.length} products with a total price of{" "} - {totalAsString}. - </p> - ) - } - tooltip={i18n.str`Manage list of products in the order.`} - > - <InventoryProductForm - currentProducts={value.inventoryProducts || {}} - onAddProduct={addProductToTheInventoryList} - inventory={instanceInventory} - /> - - {settings.advanceOrderMode && - <NonInventoryProductFrom - productToEdit={editingProduct} - onAddProduct={(p) => { - setEditingProduct(undefined); - return addNewProduct(p); - }} - /> - } - - {allProducts.length > 0 && ( - <ProductList - list={allProducts} - actions={[ - { - name: i18n.str`Remove`, - tooltip: i18n.str`Remove this product from the order.`, - handler: (e, index) => { - if (e.product_id) { - removeProductFromTheInventoryList(e.product_id); - } else { - removeFromNewProduct(index); - setEditingProduct(e); - } - }, - }, - ]} - /> - )} - </InputGroup> - - <FormProvider<Entity> - errors={errors} - object={value} - valueHandler={valueHandler as any} - > - {hasProducts ? ( - <Fragment> - <InputCurrency - name="pricing.products_price" - label={i18n.str`Total price`} - readonly - tooltip={i18n.str`total product price added up`} - /> - <InputCurrency - name="pricing.order_price" - label={i18n.str`Total price`} - addonAfter={ - discountOrRise > 0 && - (discountOrRise < 1 - ? `discount of %${Math.round( - (1 - discountOrRise) * 100, - )}` - : `rise of %${Math.round((discountOrRise - 1) * 100)}`) - } - tooltip={i18n.str`Amount to be paid by the customer`} - /> - </Fragment> - ) : ( - <InputCurrency - name="pricing.order_price" - label={i18n.str`Order price`} - tooltip={i18n.str`final order price`} - /> - )} - - <Input - name="pricing.summary" - inputType="multiline" - label={i18n.str`Summary`} - tooltip={i18n.str`Title of the order to be shown to the customer`} - /> - - {settings.advanceOrderMode && - <InputGroup - name="shipping" - label={i18n.str`Shipping and Fulfillment`} - initialActive - > - <InputDate - name="shipping.delivery_date" - label={i18n.str`Delivery date`} - tooltip={i18n.str`Deadline for physical delivery assured by the merchant.`} - /> - {value.shipping?.delivery_date && ( - <InputGroup - name="shipping.delivery_location" - label={i18n.str`Location`} - tooltip={i18n.str`address where the products will be delivered`} - > - <InputLocation name="shipping.delivery_location" /> - </InputGroup> - )} - <Input - name="shipping.fullfilment_url" - label={i18n.str`Fulfillment URL`} - tooltip={i18n.str`URL to which the user will be redirected after successful payment.`} - /> - </InputGroup> - } - - {(settings.advanceOrderMode || requiresSomeTalerOptions) && - <InputGroup - name="payments" - label={i18n.str`Taler payment options`} - tooltip={i18n.str`Override default Taler payment settings for this order`} - > - {(settings.advanceOrderMode || noDefault_payDeadline) && <InputDuration - name="payments.pay_deadline" - label={i18n.str`Payment time`} - help={<DeadlineHelp duration={value.payments?.pay_deadline} />} - withForever - withoutClear - tooltip={i18n.str`Time for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline. Time start to run after the order is created.`} - side={ - <span> - <button class="button" onClick={() => { - const c = { - ...value, - payments: { - ...(value.payments ?? {}), - pay_deadline: instance_default.payments?.pay_deadline - } - } - valueHandler(c) - }}> - <i18n.Translate>default</i18n.Translate> - </button> - </span> - } - />} - {settings.advanceOrderMode && <InputDuration - name="payments.refund_deadline" - label={i18n.str`Refund time`} - help={<DeadlineHelp duration={value.payments?.refund_deadline} />} - withForever - withoutClear - tooltip={i18n.str`Time while the order can be refunded by the merchant. Time starts after the order is created.`} - 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> - } - />} - {(settings.advanceOrderMode || noDefault_wireDeadline) && <InputDuration - name="payments.wire_transfer_deadline" - label={i18n.str`Wire transfer time`} - help={<DeadlineHelp duration={value.payments?.wire_transfer_deadline} />} - withoutClear - withForever - tooltip={i18n.str`Time for the exchange to make the wire transfer. Time starts after the order is created.`} - 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> - } - />} - {settings.advanceOrderMode && <InputDuration - name="payments.auto_refund_deadline" - label={i18n.str`Auto-refund time`} - help={<DeadlineHelp duration={value.payments?.auto_refund_deadline} />} - tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`} - withForever - />} - - {settings.advanceOrderMode && <InputCurrency - name="payments.max_fee" - label={i18n.str`Maximum fee`} - 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.`} - />} - {settings.advanceOrderMode && <InputToggle - name="payments.createToken" - label={i18n.str`Create token`} - tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`} - />} - {settings.advanceOrderMode && <InputNumber - name="payments.minimum_age" - label={i18n.str`Minimum age required`} - tooltip={i18n.str`Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products`} - help={ - minAgeByProducts > 0 - ? i18n.str`Min age defined by the producs is ${minAgeByProducts}` - : i18n.str`No product with age restriction in this order` - } - />} - </InputGroup> - } - - {settings.advanceOrderMode && - <InputGroup - name="extra" - label={i18n.str`Additional information`} - tooltip={i18n.str`Custom information to be included in the contract for this order.`} - > - {Object.keys(value.extra ?? {}).map((key) => { - - return <Input - name={`extra.${key}`} - 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> - } - </FormProvider> - - <div class="buttons is-right mt-5"> - {onBack && ( - <button class="button" onClick={onBack}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - )} - <button - class="button is-success" - onClick={submit} - disabled={hasErrors} - > - <i18n.Translate>Confirm</i18n.Translate> - </button> - </div> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} - -function asProduct(p: ProductAndQuantity): MerchantBackend.Product { - return { - product_id: p.product.id, - image: p.product.image, - price: p.product.price, - unit: p.product.unit, - quantity: p.quantity, - description: p.product.description, - taxes: p.product.taxes, - minimum_age: p.product.minimum_age, - }; -} - - -function DeadlineHelp({ duration }: { duration?: Duration }): VNode { - const { i18n } = useTranslationContext(); - const [now, setNow] = useState(AbsoluteTime.now()) - useEffect(() => { - const iid = setInterval(() => { - setNow(AbsoluteTime.now()) - }, 60 * 1000) - return () => { - clearInterval(iid) - } - }) - if (!duration) return <i18n.Translate>Disabled</i18n.Translate> - const when = AbsoluteTime.addDuration(now, duration) - if (when.t_ms === "never") return <i18n.Translate>No deadline</i18n.Translate> - return <i18n.Translate>Deadline at {format(when.t_ms, "dd/MM/yy HH:mm")}</i18n.Translate> -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx deleted file mode 100644 index 88a984c97..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* - 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { CreatedSuccessfully } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { useOrderAPI } from "../../../../hooks/order.js"; -import { Entity } from "./index.js"; - -interface Props { - entity: Entity; - onConfirm: () => void; - onCreateAnother?: () => void; -} - -export function OrderCreatedSuccessfully({ - entity, - onConfirm, - onCreateAnother, -}: Props): VNode { - const { getPaymentURL } = useOrderAPI(); - const [url, setURL] = useState<string | undefined>(undefined); - const { i18n } = useTranslationContext(); - useEffect(() => { - getPaymentURL(entity.response.order_id).then((response) => { - setURL(response.data); - }); - }, [getPaymentURL, entity.response.order_id]); - - return ( - <CreatedSuccessfully - onConfirm={onConfirm} - onCreateAnother={onCreateAnother} - > - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Amount</i18n.Translate> - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input - class="input" - readonly - value={entity.request.order.amount} - /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Summary</i18n.Translate> - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input - class="input" - readonly - value={entity.request.order.summary} - /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Order ID</i18n.Translate> - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input class="input" readonly value={entity.response.order_id} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Payment URL</i18n.Translate> - </label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input class="input" readonly value={url} /> - </p> - </div> - </div> - </div> - </CreatedSuccessfully> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx deleted file mode 100644 index 2474fd042..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* - 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 { ErrorType, HttpError } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } 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 { useInstanceDetails } from "../../../../hooks/instance.js"; -import { useOrderAPI } from "../../../../hooks/order.js"; -import { useInstanceProducts } from "../../../../hooks/product.js"; -import { Notification } from "../../../../utils/types.js"; -import { CreatePage } from "./CreatePage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -export type Entity = { - request: MerchantBackend.Orders.PostOrderRequest; - response: MerchantBackend.Orders.PostOrderResponse; -}; -interface Props { - onBack?: () => void; - onConfirm: (id: string) => void; - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; -} -export default function OrderCreate({ - onConfirm, - onBack, - onLoadError, - onNotFound, - onUnauthorized, -}: Props): VNode { - const { createOrder } = useOrderAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - - const detailsResult = useInstanceDetails(); - const inventoryResult = useInstanceProducts(); - - if (detailsResult.loading) return <Loading />; - if (inventoryResult.loading) return <Loading />; - - if (!detailsResult.ok) { - if ( - detailsResult.type === ErrorType.CLIENT && - detailsResult.status === HttpStatusCode.Unauthorized - ) - return onUnauthorized(); - if ( - detailsResult.type === ErrorType.CLIENT && - detailsResult.status === HttpStatusCode.NotFound - ) - return onNotFound(); - return onLoadError(detailsResult); - } - - if (!inventoryResult.ok) { - if ( - inventoryResult.type === ErrorType.CLIENT && - inventoryResult.status === HttpStatusCode.Unauthorized - ) - return onUnauthorized(); - if ( - inventoryResult.type === ErrorType.CLIENT && - inventoryResult.status === HttpStatusCode.NotFound - ) - return onNotFound(); - return onLoadError(inventoryResult); - } - - return ( - <Fragment> - <NotificationCard notification={notif} /> - - <CreatePage - onBack={onBack} - onCreate={(request: MerchantBackend.Orders.PostOrderRequest) => { - createOrder(request) - .then((r) => { - return onConfirm(r.data.order_id) - }) - .catch((error) => { - setNotif({ - message: "could not create order", - type: "ERROR", - description: error.message, - }); - }); - }} - instanceConfig={detailsResult.data} - instanceInventory={inventoryResult.data} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx deleted file mode 100644 index 6e73a01a5..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - 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 { addDays } from "date-fns"; -import { h, VNode, FunctionalComponent } from "preact"; -import { MerchantBackend } from "../../../../declaration.js"; -import { DetailPage as TestedComponent } from "./DetailPage.js"; - -export default { - title: "Pages/Order/Detail", - component: TestedComponent, - argTypes: { - onRefund: { action: "onRefund" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -const defaultContractTerm = { - amount: "TESTKUDOS:10", - timestamp: { - t_s: new Date().getTime() / 1000, - }, - auditors: [], - exchanges: [], - max_fee: "TESTKUDOS:1", - merchant: {} as any, - merchant_base_url: "http://merchant.url/", - order_id: "2021.165-03GDFC26Y1NNG", - products: [], - summary: "text summary", - wire_transfer_deadline: { - t_s: "never", - }, - refund_deadline: { t_s: "never" }, - merchant_pub: "ASDASDASDSd", - nonce: "QWEQWEQWE", - pay_deadline: { - t_s: "never", - }, - wire_method: "x-taler-bank", - h_wire: "asd", -} as MerchantBackend.ContractTerms; - -// contract_terms: defaultContracTerm, -export const Claimed = createExample(TestedComponent, { - id: "2021.165-03GDFC26Y1NNG", - selected: { - order_status: "claimed", - contract_terms: defaultContractTerm, - }, -}); - -export const PaidNotRefundable = createExample(TestedComponent, { - id: "2021.165-03GDFC26Y1NNG", - selected: { - order_status: "paid", - contract_terms: defaultContractTerm, - refunded: false, - deposit_total: "TESTKUDOS:10", - exchange_ec: 0, - order_status_url: "http://merchant.backend/status", - exchange_hc: 0, - refund_amount: "TESTKUDOS:0", - refund_details: [], - refund_pending: false, - wire_details: [], - wire_reports: [], - wired: false, - }, -}); - -export const PaidRefundable = createExample(TestedComponent, { - id: "2021.165-03GDFC26Y1NNG", - selected: { - order_status: "paid", - contract_terms: { - ...defaultContractTerm, - refund_deadline: { - t_s: addDays(new Date(), 2).getTime() / 1000, - }, - }, - refunded: false, - deposit_total: "TESTKUDOS:10", - exchange_ec: 0, - order_status_url: "http://merchant.backend/status", - exchange_hc: 0, - refund_amount: "TESTKUDOS:0", - refund_details: [], - refund_pending: false, - wire_details: [], - wire_reports: [], - wired: false, - }, -}); - -export const Unpaid = createExample(TestedComponent, { - id: "2021.165-03GDFC26Y1NNG", - selected: { - order_status: "unpaid", - order_status_url: "http://merchant.backend/status", - creation_time: { - t_s: new Date().getTime() / 1000, - }, - summary: "text summary", - taler_pay_uri: "pay uri", - total_amount: "TESTKUDOS:10", - }, -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx deleted file mode 100644 index 5ff76e37a..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx +++ /dev/null @@ -1,770 +0,0 @@ -/* - 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 { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { format, formatDistance } from "date-fns"; -import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { FormProvider } from "../../../../components/form/FormProvider.js"; -import { Input } from "../../../../components/form/Input.js"; -import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputDate } from "../../../../components/form/InputDate.js"; -import { InputDuration } from "../../../../components/form/InputDuration.js"; -import { InputGroup } from "../../../../components/form/InputGroup.js"; -import { InputLocation } from "../../../../components/form/InputLocation.js"; -import { TextField } from "../../../../components/form/TextField.js"; -import { ProductList } from "../../../../components/product/ProductList.js"; -import { useBackendContext } from "../../../../context/backend.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; -import { mergeRefunds } from "../../../../utils/amount.js"; -import { RefundModal } from "../list/Table.js"; -import { Event, Timeline } from "./Timeline.js"; - -type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; -type CT = MerchantBackend.ContractTerms; - -interface Props { - onBack: () => void; - selected: Entity; - id: string; - onRefund: (id: string, value: MerchantBackend.Orders.RefundRequest) => void; -} - -type Paid = MerchantBackend.Orders.CheckPaymentPaidResponse & { - refund_taken: string; -}; -type Unpaid = MerchantBackend.Orders.CheckPaymentUnpaidResponse; -type Claimed = MerchantBackend.Orders.CheckPaymentClaimedResponse; - -function ContractTerms({ value }: { value: CT }) { - const { i18n } = useTranslationContext(); - - return ( - <InputGroup name="contract_terms" label={i18n.str`Contract Terms`}> - <FormProvider<CT> object={value} valueHandler={null}> - <Input<CT> - readonly - name="summary" - label={i18n.str`Summary`} - tooltip={i18n.str`human-readable description of the whole purchase`} - /> - <InputCurrency<CT> - readonly - name="amount" - label={i18n.str`Amount`} - tooltip={i18n.str`total price for the transaction`} - /> - {value.fulfillment_url && ( - <Input<CT> - readonly - name="fulfillment_url" - label={i18n.str`Fulfillment URL`} - tooltip={i18n.str`URL for this purchase`} - /> - )} - <Input<CT> - readonly - name="max_fee" - label={i18n.str`Max fee`} - tooltip={i18n.str`maximum total deposit fee accepted by the merchant for this contract`} - /> - <InputDate<CT> - readonly - name="timestamp" - label={i18n.str`Created at`} - tooltip={i18n.str`time when this contract was generated`} - /> - <InputDate<CT> - readonly - name="refund_deadline" - label={i18n.str`Refund deadline`} - tooltip={i18n.str`after this deadline has passed no refunds will be accepted`} - /> - <InputDate<CT> - readonly - name="pay_deadline" - label={i18n.str`Payment deadline`} - tooltip={i18n.str`after this deadline, the merchant won't accept payments for the contract`} - /> - <InputDate<CT> - readonly - name="wire_transfer_deadline" - label={i18n.str`Wire transfer deadline`} - tooltip={i18n.str`transfer deadline for the exchange`} - /> - <InputDate<CT> - readonly - name="delivery_date" - label={i18n.str`Delivery date`} - tooltip={i18n.str`time indicating when the order should be delivered`} - /> - {value.delivery_date && ( - <InputGroup - name="delivery_location" - label={i18n.str`Location`} - tooltip={i18n.str`where the order will be delivered`} - > - <InputLocation name="payments.delivery_location" /> - </InputGroup> - )} - <InputDuration<CT> - readonly - name="auto_refund" - label={i18n.str`Auto-refund delay`} - tooltip={i18n.str`how long the wallet should try to get an automatic refund for the purchase`} - /> - <Input<CT> - readonly - name="extra" - label={i18n.str`Extra info`} - tooltip={i18n.str`extra data that is only interpreted by the merchant frontend`} - /> - </FormProvider> - </InputGroup> - ); -} - -function ClaimedPage({ - id, - order, -}: { - id: string; - order: MerchantBackend.Orders.CheckPaymentClaimedResponse; -}) { - const events: Event[] = []; - if (order.contract_terms.timestamp.t_s !== "never") { - events.push({ - when: new Date(order.contract_terms.timestamp.t_s * 1000), - description: "order created", - type: "start", - }); - } - if (order.contract_terms.pay_deadline.t_s !== "never") { - events.push({ - when: new Date(order.contract_terms.pay_deadline.t_s * 1000), - description: "pay deadline", - type: "deadline", - }); - } - if (order.contract_terms.refund_deadline.t_s !== "never") { - events.push({ - when: new Date(order.contract_terms.refund_deadline.t_s * 1000), - description: "refund deadline", - type: "deadline", - }); - } - if (order.contract_terms.wire_transfer_deadline.t_s !== "never") { - events.push({ - when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000), - description: "wire deadline", - type: "deadline", - }); - } - if ( - order.contract_terms.delivery_date && - order.contract_terms.delivery_date.t_s !== "never" - ) { - events.push({ - when: new Date(order.contract_terms.delivery_date?.t_s * 1000), - description: "delivery", - type: "delivery", - }); - } - - const [value, valueHandler] = useState<Partial<Claimed>>(order); - const { i18n } = useTranslationContext(); - const [settings] = useSettings() - - return ( - <div> - <section class="section"> - <div class="columns"> - <div class="column" /> - <div class="column is-10"> - <section class="hero is-hero-bar"> - <div class="hero-body"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <i18n.Translate>Order</i18n.Translate> #{id} - <div class="tag is-info ml-4"> - <i18n.Translate>claimed</i18n.Translate> - </div> - </div> - </div> - </div> - - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <h1 class="title">{order.contract_terms.amount}</h1> - </div> - </div> - </div> - - <div class="level"> - <div class="level-left" style={{ maxWidth: "100%" }}> - <div class="level-item" style={{ maxWidth: "100%" }}> - <div - class="content" - style={{ - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - }} - > - <p> - <b> - <i18n.Translate>claimed at</i18n.Translate>: - </b>{" "} - {format( - new Date(order.contract_terms.timestamp.t_s * 1000), - datetimeFormatForSettings(settings) - )} - </p> - </div> - </div> - </div> - </div> - </div> - </section> - - <section class="section"> - <div class="columns"> - <div class="column is-4"> - <div class="title"> - <i18n.Translate>Timeline</i18n.Translate> - </div> - <Timeline events={events} /> - </div> - <div class="column is-8"> - <div class="title"> - <i18n.Translate>Payment details</i18n.Translate> - </div> - <FormProvider<Claimed> - object={value} - valueHandler={valueHandler} - > - <Input - name="contract_terms.summary" - readonly - inputType="multiline" - label={i18n.str`Summary`} - /> - <InputCurrency - name="contract_terms.amount" - readonly - label={i18n.str`Amount`} - /> - <Input<Claimed> - name="order_status" - readonly - label={i18n.str`Order status`} - /> - </FormProvider> - </div> - </div> - </section> - - {order.contract_terms.products.length ? ( - <Fragment> - <div class="title"> - <i18n.Translate>Product list</i18n.Translate> - </div> - <ProductList list={order.contract_terms.products} /> - </Fragment> - ) : undefined} - - {value.contract_terms && ( - <ContractTerms value={value.contract_terms} /> - )} - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} -function PaidPage({ - id, - order, - onRefund, -}: { - id: string; - order: MerchantBackend.Orders.CheckPaymentPaidResponse; - onRefund: (id: string) => void; -}) { - const events: Event[] = []; - if (order.contract_terms.timestamp.t_s !== "never") { - events.push({ - when: new Date(order.contract_terms.timestamp.t_s * 1000), - description: "order created", - type: "start", - }); - } - if (order.contract_terms.pay_deadline.t_s !== "never") { - events.push({ - when: new Date(order.contract_terms.pay_deadline.t_s * 1000), - description: "pay deadline", - type: "deadline", - }); - } - if (order.contract_terms.refund_deadline.t_s !== "never") { - events.push({ - when: new Date(order.contract_terms.refund_deadline.t_s * 1000), - description: "refund deadline", - type: "deadline", - }); - } - if (order.contract_terms.wire_transfer_deadline.t_s !== "never") { - events.push({ - when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000), - description: "wire deadline", - type: "deadline", - }); - } - if ( - order.contract_terms.delivery_date && - order.contract_terms.delivery_date.t_s !== "never" - ) { - if (order.contract_terms.delivery_date) - events.push({ - when: new Date(order.contract_terms.delivery_date?.t_s * 1000), - description: "delivery", - type: "delivery", - }); - } - order.refund_details.reduce(mergeRefunds, []).forEach((e) => { - if (e.timestamp.t_s !== "never") { - events.push({ - when: new Date(e.timestamp.t_s * 1000), - description: `refund: ${e.amount}: ${e.reason}`, - type: e.pending ? "refund" : "refund-taken", - }); - } - }); - if (order.wire_details && order.wire_details.length) { - if (order.wire_details.length > 1) { - let last: MerchantBackend.Orders.TransactionWireTransfer | null = null; - let first: MerchantBackend.Orders.TransactionWireTransfer | null = null; - let total: AmountJson | null = null; - - order.wire_details.forEach((w) => { - if (last === null || last.execution_time.t_s < w.execution_time.t_s) { - last = w; - } - if (first === null || first.execution_time.t_s > w.execution_time.t_s) { - first = w; - } - total = - total === null - ? Amounts.parseOrThrow(w.amount) - : Amounts.add(total, Amounts.parseOrThrow(w.amount)).amount; - }); - const last_time = last!.execution_time.t_s; - if (last_time !== "never") { - events.push({ - when: new Date(last_time * 1000), - description: `wired ${Amounts.stringify(total!)}`, - type: "wired-range", - }); - } - const first_time = first!.execution_time.t_s; - if (first_time !== "never") { - events.push({ - when: new Date(first_time * 1000), - description: `wire transfer started...`, - type: "wired-range", - }); - } - } else { - order.wire_details.forEach((e) => { - if (e.execution_time.t_s !== "never") { - events.push({ - when: new Date(e.execution_time.t_s * 1000), - description: `wired ${e.amount}`, - type: "wired", - }); - } - }); - } - } - - const now = new Date() - const nextEvent = events.find((e) => { - return e.when.getTime() > now.getTime() - }) - - const [value, valueHandler] = useState<Partial<Paid>>(order); - const { url: backendURL } = useBackendContext() - const refundurl = stringifyRefundUri({ - merchantBaseUrl: backendURL, - orderId: order.contract_terms.order_id - }) - const refundable = - new Date().getTime() < order.contract_terms.refund_deadline.t_s * 1000; - const { i18n } = useTranslationContext(); - - const amount = Amounts.parseOrThrow(order.contract_terms.amount); - const refund_taken = order.refund_details.reduce((prev, cur) => { - if (cur.pending) return prev; - return Amounts.add(prev, Amounts.parseOrThrow(cur.amount)).amount; - }, Amounts.zeroOfCurrency(amount.currency)); - value.refund_taken = Amounts.stringify(refund_taken); - - return ( - <div> - <section class="section"> - <div class="columns"> - <div class="column" /> - <div class="column is-10"> - <section class="hero is-hero-bar"> - <div class="hero-body"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <i18n.Translate>Order</i18n.Translate> #{id} - <div class="tag is-success ml-4"> - <i18n.Translate>paid</i18n.Translate> - </div> - {order.wired ? ( - <div class="tag is-success ml-4"> - <i18n.Translate>wired</i18n.Translate> - </div> - ) : null} - {order.refunded ? ( - <div class="tag is-danger ml-4"> - <i18n.Translate>refunded</i18n.Translate> - </div> - ) : null} - </div> - </div> - </div> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <h1 class="title">{order.contract_terms.amount}</h1> - </div> - </div> - <div class="level-right"> - <div class="level-item"> - <h1 class="title"> - <div class="buttons"> - <span - class="has-tooltip-left" - data-tooltip={ - refundable - ? i18n.str`refund order` - : i18n.str`not refundable` - } - > - <button - class="button is-danger" - disabled={!refundable} - onClick={() => onRefund(id)} - > - <i18n.Translate>refund</i18n.Translate> - </button> - </span> - </div> - </h1> - </div> - </div> - </div> - - <div class="level"> - <div class="level-left" style={{ maxWidth: "100%" }}> - <div class="level-item" style={{ maxWidth: "100%" }}> - <div - class="content" - style={{ - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - }} - > - {nextEvent && - <p> - <i18n.Translate>Next event in </i18n.Translate> {formatDistance( - nextEvent.when, - new Date(), - // "yyyy/MM/dd HH:mm:ss", - )} - </p> - } - </div> - </div> - </div> - </div> - </div> - </section> - - <section class="section"> - <div class="columns"> - <div class="column is-4"> - <div class="title"> - <i18n.Translate>Timeline</i18n.Translate> - </div> - <Timeline events={events} /> - </div> - <div class="column is-8"> - <div class="title"> - <i18n.Translate>Payment details</i18n.Translate> - </div> - <FormProvider<Paid> - object={value} - valueHandler={valueHandler} - > - {/* <InputCurrency<Paid> name="deposit_total" readonly label={i18n.str`Deposit total`} /> */} - {order.refunded && ( - <InputCurrency<Paid> - name="refund_amount" - readonly - label={i18n.str`Refunded amount`} - /> - )} - {order.refunded && ( - <InputCurrency<Paid> - name="refund_taken" - readonly - label={i18n.str`Refund taken`} - /> - )} - <Input<Paid> - name="order_status" - readonly - label={i18n.str`Order status`} - /> - <TextField<Paid> - name="order_status_url" - label={i18n.str`Status URL`} - > - <a - target="_blank" - rel="noreferrer" - href={order.order_status_url} - > - {order.order_status_url} - </a> - </TextField> - {order.refunded && ( - <TextField<Paid> - name="order_status_url" - label={i18n.str`Refund URI`} - > - <a target="_blank" rel="noreferrer" href={refundurl}> - {refundurl} - </a> - </TextField> - )} - </FormProvider> - </div> - </div> - </section> - - {order.contract_terms.products.length ? ( - <Fragment> - <div class="title"> - <i18n.Translate>Product list</i18n.Translate> - </div> - <ProductList list={order.contract_terms.products} /> - </Fragment> - ) : undefined} - - {value.contract_terms && ( - <ContractTerms value={value.contract_terms} /> - )} - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} - -function UnpaidPage({ - id, - order, -}: { - id: string; - order: MerchantBackend.Orders.CheckPaymentUnpaidResponse; -}) { - const [value, valueHandler] = useState<Partial<Unpaid>>(order); - const { i18n } = useTranslationContext(); - const [settings] = useSettings() - return ( - <div> - <section class="hero is-hero-bar"> - <div class="hero-body"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <h1 class="title"> - <i18n.Translate>Order</i18n.Translate> #{id} - </h1> - </div> - <div class="tag is-dark"> - <i18n.Translate>unpaid</i18n.Translate> - </div> - </div> - </div> - - <div class="level"> - <div class="level-left" style={{ maxWidth: "100%" }}> - <div class="level-item" style={{ maxWidth: "100%" }}> - <div - class="content" - style={{ - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - }} - > - <p> - <b> - <i18n.Translate>pay at</i18n.Translate>: - </b>{" "} - <a - href={order.order_status_url} - rel="nofollow" - target="new" - > - {order.order_status_url} - </a> - </p> - <p> - <b> - <i18n.Translate>created at</i18n.Translate>: - </b>{" "} - {order.creation_time.t_s === "never" - ? "never" - : format( - new Date(order.creation_time.t_s * 1000), - datetimeFormatForSettings(settings) - )} - </p> - </div> - </div> - </div> - </div> - </div> - </section> - - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <FormProvider<Unpaid> object={value} valueHandler={valueHandler}> - <Input<Unpaid> - readonly - name="summary" - label={i18n.str`Summary`} - tooltip={i18n.str`human-readable description of the whole purchase`} - /> - <InputCurrency<Unpaid> - readonly - name="total_amount" - label={i18n.str`Amount`} - tooltip={i18n.str`total price for the transaction`} - /> - <Input<Unpaid> - name="order_status" - readonly - label={i18n.str`Order status`} - /> - <Input<Unpaid> - name="order_status_url" - readonly - label={i18n.str`Order status URL`} - /> - <TextField<Unpaid> - name="taler_pay_uri" - label={i18n.str`Payment URI`} - > - <a target="_blank" rel="noreferrer" href={value.taler_pay_uri}> - {value.taler_pay_uri} - </a> - </TextField> - </FormProvider> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} - -export function DetailPage({ id, selected, onRefund, onBack }: Props): VNode { - const [showRefund, setShowRefund] = useState<string | undefined>(undefined); - const { i18n } = useTranslationContext(); - const DetailByStatus = function () { - switch (selected.order_status) { - case "claimed": - return <ClaimedPage id={id} order={selected} />; - case "paid": - return <PaidPage id={id} order={selected} onRefund={setShowRefund} />; - case "unpaid": - return <UnpaidPage id={id} order={selected} />; - default: - return ( - <div> - <i18n.Translate> - Unknown order status. This is an error, please contact the - administrator. - </i18n.Translate> - </div> - ); - } - }; - - return ( - <Fragment> - {DetailByStatus()} - {showRefund && ( - <RefundModal - order={selected} - onCancel={() => setShowRefund(undefined)} - onConfirm={(value) => { - onRefund(showRefund, value); - setShowRefund(undefined); - }} - /> - )} - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <div class="buttons is-right mt-5"> - <button class="button" onClick={onBack}> - <i18n.Translate>Back</i18n.Translate> - </button> - </div> - </div> - <div class="column" /> - </div> - </Fragment> - ); -} - -async function copyToClipboard(text: string) { - return navigator.clipboard.writeText(text); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx deleted file mode 100644 index 8c863f386..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/* - 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 { format } from "date-fns"; -import { h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; - -interface Props { - events: Event[]; -} - -export function Timeline({ events: e }: Props) { - const events = [...e]; - events.push({ - when: new Date(), - description: "now", - type: "now", - }); - - events.sort((a, b) => a.when.getTime() - b.when.getTime()); - const [settings] = useSettings(); - const [state, setState] = useState(events); - useEffect(() => { - const handle = setTimeout(() => { - const eventsWithoutNow = state.filter((e) => e.type !== "now"); - eventsWithoutNow.push({ - when: new Date(), - description: "now", - type: "now", - }); - setState(eventsWithoutNow); - }, 1000); - return () => { - clearTimeout(handle); - }; - }); - return ( - <div class="timeline"> - {events.map((e, i) => { - return ( - <div key={i} class="timeline-item"> - {(() => { - switch (e.type) { - case "deadline": - return ( - <div class="timeline-marker is-icon "> - <i class="mdi mdi-flag" /> - </div> - ); - case "delivery": - return ( - <div class="timeline-marker is-icon "> - <i class="mdi mdi-delivery" /> - </div> - ); - case "start": - return ( - <div class="timeline-marker is-icon"> - <i class="mdi mdi-flag " /> - </div> - ); - case "wired": - return ( - <div class="timeline-marker is-icon is-success"> - <i class="mdi mdi-cash" /> - </div> - ); - case "wired-range": - return ( - <div class="timeline-marker is-icon is-success"> - <i class="mdi mdi-cash" /> - </div> - ); - case "refund": - return ( - <div class="timeline-marker is-icon is-danger"> - <i class="mdi mdi-cash" /> - </div> - ); - case "refund-taken": - return ( - <div class="timeline-marker is-icon is-success"> - <i class="mdi mdi-cash" /> - </div> - ); - case "now": - return ( - <div class="timeline-marker is-icon is-info"> - <i class="mdi mdi-clock" /> - </div> - ); - } - })()} - <div class="timeline-content"> - {e.description !== "now" && <p class="heading">{format(e.when, datetimeFormatForSettings(settings))}</p>} - <p>{e.description}</p> - </div> - </div> - ); - })} - </div> - ); -} -export interface Event { - when: Date; - description: string; - type: - | "start" - | "refund" - | "refund-taken" - | "wired" - | "wired-range" - | "deadline" - | "delivery" - | "now"; -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/index.tsx deleted file mode 100644 index 1517a3c42..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/index.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - 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 { - useTranslationContext, - HttpError, - ErrorType, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } 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 { useOrderAPI, useOrderDetails } from "../../../../hooks/order.js"; -import { Notification } from "../../../../utils/types.js"; -import { DetailPage } from "./DetailPage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -export interface Props { - oid: string; - - onBack: () => void; - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; -} - -export default function Update({ - oid, - onBack, - onLoadError, - onNotFound, - onUnauthorized, -}: Props): VNode { - const { refundOrder } = useOrderAPI(); - const result = useOrderDetails(oid); - 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} /> - - <DetailPage - onBack={onBack} - id={oid} - onRefund={(id, value) => - refundOrder(id, value) - .then(() => - setNotif({ - message: i18n.str`refund created successfully`, - type: "SUCCESS", - }), - ) - .catch((error) => - setNotif({ - message: i18n.str`could not create the refund`, - type: "ERROR", - description: error.message, - }), - ) - } - selected={result.data} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx deleted file mode 100644 index 156c577f4..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - 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 { ListPage as TestedComponent } from "./ListPage.js"; - -export default { - title: "Pages/Order/List", - component: TestedComponent, - argTypes: { - onShowAll: { action: "onShowAll" }, - onShowPaid: { action: "onShowPaid" }, - onShowRefunded: { action: "onShowRefunded" }, - onShowNotWired: { action: "onShowNotWired" }, - onCopyURL: { action: "onCopyURL" }, - onSelectDate: { action: "onSelectDate" }, - onLoadMoreBefore: { action: "onLoadMoreBefore" }, - onLoadMoreAfter: { action: "onLoadMoreAfter" }, - onSelectOrder: { action: "onSelectOrder" }, - onRefundOrder: { action: "onRefundOrder" }, - onSearchOrderById: { action: "onSearchOrderById" }, - onCreate: { action: "onCreate" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, { - orders: [ - { - id: "123", - amount: "TESTKUDOS:10", - paid: false, - refundable: true, - row_id: 1, - summary: "summary", - timestamp: { - t_s: new Date().getTime() / 1000, - }, - order_id: "123", - }, - { - id: "234", - amount: "TESTKUDOS:12", - paid: true, - refundable: true, - row_id: 2, - summary: - "summary with long text, very very long text that someone want to add as a description of the order", - timestamp: { - t_s: new Date().getTime() / 1000, - }, - order_id: "234", - }, - { - id: "456", - amount: "TESTKUDOS:1", - paid: false, - refundable: false, - row_id: 3, - summary: - "summary with long text, very very long text that someone want to add as a description of the order", - timestamp: { - t_s: new Date().getTime() / 1000, - }, - order_id: "456", - }, - { - id: "234", - amount: "TESTKUDOS:12", - paid: false, - refundable: false, - row_id: 4, - summary: - "summary with long text, very very long text that someone want to add as a description of the order", - timestamp: { - t_s: new Date().getTime() / 1000, - }, - order_id: "234", - }, - ], -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx deleted file mode 100644 index 9f80719a1..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/* - 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 { format } from "date-fns"; -import { h, VNode, Fragment } from "preact"; -import { useState } from "preact/hooks"; -import { DatePicker } from "../../../../components/picker/DatePicker.js"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { CardTable } from "./Table.js"; -import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; - -export interface ListPageProps { - onShowAll: () => void; - onShowNotPaid: () => void; - onShowPaid: () => void; - onShowRefunded: () => void; - onShowNotWired: () => void; - onShowWired: () => void; - onCopyURL: (id: string) => void; - isAllActive: string; - isPaidActive: string; - isNotPaidActive: string; - isRefundedActive: string; - isNotWiredActive: string; - isWiredActive: string; - - jumpToDate?: Date; - onSelectDate: (date?: Date) => void; - - orders: (MerchantBackend.Orders.OrderHistoryEntry & WithId)[]; - onLoadMoreBefore?: () => void; - hasMoreBefore?: boolean; - hasMoreAfter?: boolean; - onLoadMoreAfter?: () => void; - - onSelectOrder: (o: MerchantBackend.Orders.OrderHistoryEntry & WithId) => void; - onRefundOrder: (o: MerchantBackend.Orders.OrderHistoryEntry & WithId) => void; - onCreate: () => void; -} - -export function ListPage({ - hasMoreAfter, - hasMoreBefore, - onLoadMoreAfter, - onLoadMoreBefore, - orders, - isAllActive, - onSelectOrder, - onRefundOrder, - jumpToDate, - onCopyURL, - onShowAll, - onShowPaid, - onShowNotPaid, - onShowRefunded, - onShowNotWired, - onShowWired, - onSelectDate, - isPaidActive, - isRefundedActive, - isNotWiredActive, - onCreate, - isNotPaidActive, - isWiredActive, -}: ListPageProps): VNode { - const { i18n } = useTranslationContext(); - const dateTooltip = i18n.str`select date to show nearby orders`; - const [pickDate, setPickDate] = useState(false); - const [settings] = useSettings(); - - return ( - <Fragment> - <div class="columns"> - <div class="column is-two-thirds"> - <div class="tabs" style={{ overflow: "inherit" }}> - <ul> - <li class={isNotPaidActive}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`only show paid orders`} - > - <a onClick={onShowNotPaid}> - <i18n.Translate>New</i18n.Translate> - </a> - </div> - </li> - <li class={isPaidActive}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`only show paid orders`} - > - <a onClick={onShowPaid}> - <i18n.Translate>Paid</i18n.Translate> - </a> - </div> - </li> - <li class={isRefundedActive}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`only show orders with refunds`} - > - <a onClick={onShowRefunded}> - <i18n.Translate>Refunded</i18n.Translate> - </a> - </div> - </li> - <li class={isNotWiredActive}> - <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={onShowNotWired}> - <i18n.Translate>Not wired</i18n.Translate> - </a> - </div> - </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> - </div> - </div> - <div class="column "> - <div class="buttons is-right"> - <div class="field has-addons"> - {jumpToDate && ( - <div class="control"> - <a class="button is-fullwidth" onClick={() => onSelectDate(undefined)}> - <span - class="icon" - data-tooltip={i18n.str`clear date filter`} - > - <i class="mdi mdi-close" /> - </span> - </a> - </div> - )} - <div class="control"> - <span class="has-tooltip-top" data-tooltip={dateTooltip}> - <input - class="input" - type="text" - readonly - value={!jumpToDate ? "" : format(jumpToDate, dateFormatForSettings(settings))} - placeholder={i18n.str`date (${dateFormatForSettings(settings)})`} - onClick={() => { - setPickDate(true); - }} - /> - </span> - </div> - <div class="control"> - <span class="has-tooltip-left" data-tooltip={dateTooltip}> - <a - class="button is-fullwidth" - onClick={() => { - setPickDate(true); - }} - > - <span class="icon"> - <i class="mdi mdi-calendar" /> - </span> - </a> - </span> - </div> - </div> - </div> - </div> - </div> - - <DatePicker - opened={pickDate} - closeFunction={() => setPickDate(false)} - dateReceiver={onSelectDate} - /> - - <CardTable - orders={orders} - onCreate={onCreate} - onCopyURL={onCopyURL} - onSelect={onSelectOrder} - onRefund={onRefundOrder} - hasMoreAfter={hasMoreAfter} - hasMoreBefore={hasMoreBefore} - onLoadMoreAfter={onLoadMoreAfter} - onLoadMoreBefore={onLoadMoreBefore} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/Table.tsx deleted file mode 100644 index b2806bb79..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/Table.tsx +++ /dev/null @@ -1,417 +0,0 @@ -/* - 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 { Amounts } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { h, VNode } from "preact"; -import { StateUpdater, useState } from "preact/hooks"; -import { - FormErrors, - FormProvider, -} from "../../../../components/form/FormProvider.js"; -import { Input } from "../../../../components/form/Input.js"; -import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputGroup } from "../../../../components/form/InputGroup.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; -import { ConfirmModal } from "../../../../components/modal/index.js"; -import { useConfigContext } from "../../../../context/config.js"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { mergeRefunds } from "../../../../utils/amount.js"; -import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; - -type Entity = MerchantBackend.Orders.OrderHistoryEntry & WithId; -interface Props { - orders: Entity[]; - onRefund: (value: Entity) => void; - onCopyURL: (id: string) => void; - onCreate: () => void; - onSelect: (order: Entity) => void; - onLoadMoreBefore?: () => void; - hasMoreBefore?: boolean; - hasMoreAfter?: boolean; - onLoadMoreAfter?: () => void; -} - -export function CardTable({ - orders, - onCreate, - onRefund, - onCopyURL, - 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-cash-register" /> - </span> - <i18n.Translate>Orders</i18n.Translate> - </p> - - <div class="card-header-icon" aria-label="more options" /> - - <div class="card-header-icon" aria-label="more options"> - <span class="has-tooltip-left" data-tooltip={i18n.str`create order`}> - <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"> - {orders.length > 0 ? ( - <Table - instances={orders} - onSelect={onSelect} - onRefund={onRefund} - onCopyURL={(o) => onCopyURL(o.id)} - rowSelection={rowSelection} - rowSelectionHandler={rowSelectionHandler} - onLoadMoreAfter={onLoadMoreAfter} - onLoadMoreBefore={onLoadMoreBefore} - hasMoreAfter={hasMoreAfter} - hasMoreBefore={hasMoreBefore} - /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - ); -} -interface TableProps { - rowSelection: string[]; - instances: Entity[]; - onRefund: (id: Entity) => void; - onCopyURL: (id: Entity) => void; - onSelect: (id: Entity) => void; - rowSelectionHandler: StateUpdater<string[]>; - onLoadMoreBefore?: () => void; - hasMoreBefore?: boolean; - hasMoreAfter?: boolean; - onLoadMoreAfter?: () => void; -} - -function Table({ - instances, - onSelect, - onRefund, - onCopyURL, - onLoadMoreAfter, - onLoadMoreBefore, - hasMoreAfter, - hasMoreBefore, -}: TableProps): VNode { - const { i18n } = useTranslationContext(); - const [settings] = useSettings(); - return ( - <div class="table-container"> - {hasMoreBefore && ( - <button - class="button is-fullwidth" - onClick={onLoadMoreBefore} - > - <i18n.Translate>load newer orders</i18n.Translate> - </button> - )} - <table class="table is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th style={{ minWidth: 100 }}> - <i18n.Translate>Date</i18n.Translate> - </th> - <th style={{ minWidth: 100 }}> - <i18n.Translate>Amount</i18n.Translate> - </th> - <th style={{ minWidth: 400 }}> - <i18n.Translate>Summary</i18n.Translate> - </th> - <th style={{ minWidth: 50 }} /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - return ( - <tr key={i.id}> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.timestamp.t_s === "never" - ? "never" - : format( - new Date(i.timestamp.t_s * 1000), - datetimeFormatForSettings(settings), - )} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.amount} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.summary} - </td> - <td class="is-actions-cell right-sticky"> - <div class="buttons is-right"> - {i.refundable && ( - <button - class="button is-small is-danger jb-modal" - type="button" - onClick={(): void => onRefund(i)} - > - <i18n.Translate>Refund</i18n.Translate> - </button> - )} - {!i.paid && ( - <button - class="button is-small is-info jb-modal" - type="button" - onClick={(): void => onCopyURL(i)} - > - <i18n.Translate>copy url</i18n.Translate> - </button> - )} - </div> - </td> - </tr> - ); - })} - </tbody> - </table> - {hasMoreAfter && ( - <button - class="button is-fullwidth" - onClick={onLoadMoreAfter} - > - <i18n.Translate>load older orders</i18n.Translate> - </button> - )} - </div> - ); -} - -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> - No orders have been found matching your query! - </i18n.Translate> - </p> - </div> - ); -} - -interface RefundModalProps { - onCancel: () => void; - onConfirm: (value: MerchantBackend.Orders.RefundRequest) => void; - order: MerchantBackend.Orders.MerchantOrderStatusResponse; -} - -export function RefundModal({ - order, - onCancel, - onConfirm, -}: RefundModalProps): VNode { - type State = { mainReason?: string; description?: string; refund?: string }; - const [form, setValue] = useState<State>({}); - const [settings] = useSettings(); - const { i18n } = useTranslationContext(); - // const [errors, setErrors] = useState<FormErrors<State>>({}); - - const refunds = ( - order.order_status === "paid" ? order.refund_details : [] - ).reduce(mergeRefunds, []); - - const config = useConfigContext(); - const totalRefunded = refunds - .map((r) => r.amount) - .reduce( - (p, c) => Amounts.add(p, Amounts.parseOrThrow(c)).amount, - Amounts.zeroOfCurrency(config.currency), - ); - const orderPrice = - order.order_status === "paid" - ? Amounts.parseOrThrow(order.contract_terms.amount) - : undefined; - const totalRefundable = !orderPrice - ? Amounts.zeroOfCurrency(totalRefunded.currency) - : refunds.length - ? Amounts.sub(orderPrice, totalRefunded).amount - : orderPrice; - - const isRefundable = Amounts.isNonZero(totalRefundable); - const duplicatedText = i18n.str`duplicated`; - - const errors: FormErrors<State> = { - mainReason: !form.mainReason ? i18n.str`required` : undefined, - description: - !form.description && form.mainReason !== duplicatedText - ? i18n.str`required` - : undefined, - refund: !form.refund - ? i18n.str`required` - : !Amounts.parse(form.refund) - ? i18n.str`invalid format` - : Amounts.cmp(totalRefundable, Amounts.parse(form.refund)!) === -1 - ? i18n.str`this value exceed the refundable amount` - : undefined, - }; - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const validateAndConfirm = () => { - try { - if (!form.refund) return; - onConfirm({ - refund: Amounts.stringify( - Amounts.add(Amounts.parse(form.refund)!, totalRefunded).amount, - ), - reason: - form.description === undefined - ? form.mainReason || "" - : `${form.mainReason}: ${form.description}`, - }); - } catch (err) { - console.log(err); - } - }; - - //FIXME: parameters in the translation - return ( - <ConfirmModal - description="refund" - danger - active - disabled={!isRefundable || hasErrors} - onCancel={onCancel} - onConfirm={validateAndConfirm} - > - {refunds.length > 0 && ( - <div class="columns"> - <div class="column is-12"> - <InputGroup - name="asd" - label={`${Amounts.stringify(totalRefunded)} was already refunded`} - > - <table class="table is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>date</i18n.Translate> - </th> - <th> - <i18n.Translate>amount</i18n.Translate> - </th> - <th> - <i18n.Translate>reason</i18n.Translate> - </th> - </tr> - </thead> - <tbody> - {refunds.map((r) => { - return ( - <tr key={r.timestamp.t_s}> - <td> - {r.timestamp.t_s === "never" - ? "never" - : format( - new Date(r.timestamp.t_s * 1000), - datetimeFormatForSettings(settings), - )} - </td> - <td>{r.amount}</td> - <td>{r.reason}</td> - </tr> - ); - })} - </tbody> - </table> - </InputGroup> - </div> - </div> - )} - - {isRefundable && ( - <FormProvider<State> - errors={errors} - object={form} - valueHandler={(d) => setValue(d as any)} - > - <InputCurrency<State> - name="refund" - label={i18n.str`Refund`} - tooltip={i18n.str`amount to be refunded`} - > - <i18n.Translate>Max refundable:</i18n.Translate>{" "} - {Amounts.stringify(totalRefundable)} - </InputCurrency> - <InputSelector - name="mainReason" - label={i18n.str`Reason`} - values={[ - i18n.str`Choose one...`, - duplicatedText, - i18n.str`requested by the customer`, - i18n.str`other`, - ]} - tooltip={i18n.str`why this order is being refunded`} - /> - {form.mainReason && form.mainReason !== duplicatedText ? ( - <Input<State> - label={i18n.str`Description`} - name="description" - tooltip={i18n.str`more information to give context`} - /> - ) : undefined} - </FormProvider> - )} - </ConfirmModal> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/index.tsx deleted file mode 100644 index 92e714fb8..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/index.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } 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 { - InstanceOrderFilter, - useInstanceOrders, - useOrderAPI, - useOrderDetails, -} from "../../../../hooks/order.js"; -import { Notification } from "../../../../utils/types.js"; -import { ListPage } from "./ListPage.js"; -import { RefundModal } from "./Table.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { JumpToElementById } from "../../../../components/form/JumpToElementById.js"; - -interface Props { - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onNotFound: () => VNode; - onSelect: (id: string) => void; - onCreate: () => void; -} - -export default function OrderList({ - onUnauthorized, - onLoadError, - onCreate, - onSelect, - onNotFound, -}: Props): VNode { - const [filter, setFilter] = useState<InstanceOrderFilter>({ paid: "no" }); - const [orderToBeRefunded, setOrderToBeRefunded] = useState< - MerchantBackend.Orders.OrderHistoryEntry | undefined - >(undefined); - - const setNewDate = (date?: Date): void => - setFilter((prev) => ({ ...prev, date })); - - const result = useInstanceOrders(filter, setNewDate); - const { refundOrder, getPaymentURL } = useOrderAPI(); - - 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); - } - - 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 isNotWiredActive = filter.wired === "no" && filter.paid === "yes" ? "is-active" : ""; - const isWiredActive = filter.wired === "yes" ? "is-active" : ""; - const isAllActive = - filter.paid === undefined && - filter.refunded === undefined && - filter.wired === undefined - ? "is-active" - : ""; - - return ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> - - <JumpToElementById - testIfExist={getPaymentURL} - onSelect={onSelect} - description={i18n.str`jump to order with the given product ID`} - placeholder={i18n.str`order id`} - /> - - <ListPage - orders={result.data.orders.map((o) => ({ ...o, id: o.order_id }))} - onLoadMoreBefore={result.loadMorePrev} - hasMoreBefore={!result.isReachingStart} - onLoadMoreAfter={result.loadMore} - hasMoreAfter={!result.isReachingEnd} - onSelectOrder={(order) => onSelect(order.id)} - onRefundOrder={(value) => setOrderToBeRefunded(value)} - isAllActive={isAllActive} - isNotWiredActive={isNotWiredActive} - isWiredActive={isWiredActive} - isPaidActive={isPaidActive} - isNotPaidActive={isNotPaidActive} - isRefundedActive={isRefundedActive} - jumpToDate={filter.date} - onCopyURL={(id) => - getPaymentURL(id).then((resp) => copyToClipboard(resp.data)) - } - onCreate={onCreate} - onSelectDate={setNewDate} - onShowAll={() => setFilter({})} - onShowNotPaid={() => setFilter({ paid: "no" })} - onShowPaid={() => setFilter({ paid: "yes" })} - onShowRefunded={() => setFilter({ refunded: "yes" })} - onShowNotWired={() => setFilter({ wired: "no", paid: "yes" })} - onShowWired={() => setFilter({ wired: "yes" })} - /> - - {orderToBeRefunded && ( - <RefundModalForTable - id={orderToBeRefunded.order_id} - onCancel={() => setOrderToBeRefunded(undefined)} - onConfirm={(value) => - refundOrder(orderToBeRefunded.order_id, value) - .then(() => - setNotif({ - message: i18n.str`refund created successfully`, - type: "SUCCESS", - }), - ) - .catch((error) => - setNotif({ - message: i18n.str`could not create the refund`, - type: "ERROR", - description: error.message, - }), - ) - .then(() => setOrderToBeRefunded(undefined)) - } - onLoadError={(error) => { - setNotif({ - message: i18n.str`could not create the refund`, - type: "ERROR", - description: error.message, - }); - setOrderToBeRefunded(undefined); - return <div />; - }} - onUnauthorized={onUnauthorized} - onNotFound={() => { - setNotif({ - message: i18n.str`could not get the order to refund`, - type: "ERROR", - // description: error.message - }); - setOrderToBeRefunded(undefined); - return <div />; - }} - /> - )} - </section> - ); -} - -interface RefundProps { - id: string; - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onNotFound: () => VNode; - onCancel: () => void; - onConfirm: (m: MerchantBackend.Orders.RefundRequest) => void; -} - -function RefundModalForTable({ - id, - onUnauthorized, - onLoadError, - onNotFound, - onConfirm, - onCancel, -}: RefundProps): VNode { - const result = useOrderDetails(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 ( - <RefundModal - order={result.data} - onCancel={onCancel} - onConfirm={onConfirm} - /> - ); -} - -async function copyToClipboard(text: string): Promise<void> { - return navigator.clipboard.writeText(text); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx deleted file mode 100644 index 26f851cc8..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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/OtpDevices/Create", - component: TestedComponent, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx deleted file mode 100644 index ffeaa064a..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx +++ /dev/null @@ -1,179 +0,0 @@ -/* - 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 { isRfc3548Base32Charset, randomRfc3548Base32Key } from "@gnu-taler/taler-util"; -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 { InputSelector } from "../../../../components/form/InputSelector.js"; -import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; -import { useBackendContext } from "../../../../context/backend.js"; -import { MerchantBackend } from "../../../../declaration.js"; - -type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; - -interface Props { - onCreate: (d: Entity) => Promise<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 { - const { i18n } = useTranslationContext(); - const backend = useBackendContext(); - - const [state, setState] = useState<Partial<Entity>>({}); - - const [showKey, setShowKey] = useState(false); - - const errors: FormErrors<Entity> = { - otp_device_id: !state.otp_device_id - ? i18n.str`required` - : !/[a-zA-Z0-9]*/.test(state.otp_device_id) - ? i18n.str`no valid. only characters and numbers` - : undefined, - otp_algorithm: !state.otp_algorithm ? i18n.str`required` : undefined, - otp_key: !state.otp_key - ? i18n.str`required` - : !isRfc3548Base32Charset(state.otp_key) - ? i18n.str`just letters and numbers from 2 to 7` - : state.otp_key.length !== 32 - ? i18n.str`size of the key should be 32` - : undefined, - otp_device_description: !state.otp_device_description - ? i18n.str`required` - : !/[a-zA-Z0-9]*/.test(state.otp_device_description) - ? i18n.str`no valid. only characters and numbers` - : undefined, - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submitForm = () => { - if (hasErrors) return Promise.reject(); - 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} - > - <Input<Entity> - name="otp_device_id" - label={i18n.str`ID`} - tooltip={i18n.str`Internal id on the system`} - /> - <Input<Entity> - name="otp_device_description" - label={i18n.str`Descripiton`} - tooltip={i18n.str`Useful to identify the device physically`} - /> - <InputSelector<Entity> - name="otp_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.otp_algorithm && state.otp_algorithm > 0 ? ( - <Fragment> - <InputWithAddon<Entity> - expand - name="otp_key" - label={i18n.str`Device key`} - inputType={showKey ? "text" : "password"} - help="Be sure to be very hard to guess or use the random generator" - tooltip={i18n.str`Your device need to have exactly the same value`} - fromStr={(v) => v.toUpperCase()} - addonAfterAction={() => { - setShowKey(!showKey); - }} - addonAfter={ - <span class="icon"> - {showKey ? ( - <i class="mdi mdi-eye" /> - ) : ( - <i class="mdi mdi-eye-off" /> - )} - </span> - } - side={ - <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-3" - onClick={(e) => { - setState((s) => ({ - ...s, - otp_key: randomRfc3548Base32Key(), - })); - }} - > - <i18n.Translate>random</i18n.Translate> - </button> - } - /> - </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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx deleted file mode 100644 index db3842711..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/* - 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { QR } from "../../../../components/exception/QR.js"; -import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { useInstanceContext } from "../../../../context/instance.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useBackendContext } from "../../../../context/backend.js"; - -type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; - -interface Props { - entity: Entity; - onConfirm: () => void; -} - -function isNotUndefined<X>(x: X | undefined): x is X { - return !!x; -} - -export function CreatedSuccessfully({ - entity, - onConfirm, -}: Props): VNode { - const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext() - const { id: instanceId } = useInstanceContext(); - const issuer = new URL(backendURL).hostname; - const qrText = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`; - const qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`; - - return ( - <Template onConfirm={onConfirm} > - <p class="is-size-5"> - <i18n.Translate> - You can scan the next QR code with your device or safe the key before continue. - </i18n.Translate> - </p> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">ID</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input - readonly - class="input" - value={entity.otp_device_id} - /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"><i18n.Translate>Description</i18n.Translate></label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input - class="input" - readonly - value={entity.otp_device_description} - /> - </p> - </div> - </div> - </div> - <QR - text={qrText} - /> - <div - style={{ - color: "grey", - fontSize: "small", - width: 200, - textAlign: "center", - margin: "auto", - wordBreak: "break-all", - }} - > - {qrTextSafe} - </div> - </Template> - ); -} - diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx deleted file mode 100644 index 648846793..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - 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 { CreatedSuccessfully } from "./CreatedSuccessfully.js"; - -export type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; -interface Props { - onBack?: () => void; - onConfirm: () => void; -} - -export default function CreateValidator({ onConfirm, onBack }: Props): VNode { - const { createOtpDevice } = useOtpDeviceAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - const [created, setCreated] = useState<MerchantBackend.OTP.OtpDeviceAddDetails | null>(null) - - if (created) { - return <CreatedSuccessfully entity={created} onConfirm={onConfirm} /> - } - - return ( - <> - <NotificationCard notification={notif} /> - <CreatePage - onBack={onBack} - onCreate={(request: Entity) => { - return createOtpDevice(request) - .then((d) => { - setCreated(request) - }) - .catch((error) => { - setNotif({ - message: i18n.str`could not create device`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx deleted file mode 100644 index b18049674..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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/OtpDevices/List", - component: TestedComponent, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx deleted file mode 100644 index 4efee9781..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - 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.OTP.OtpDeviceEntry[]; - onLoadMoreBefore?: () => void; - onLoadMoreAfter?: () => void; - onCreate: () => void; - onDelete: (e: MerchantBackend.OTP.OtpDeviceEntry) => void; - onSelect: (e: MerchantBackend.OTP.OtpDeviceEntry) => 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 - devices={devices.map((o) => ({ - ...o, - id: String(o.otp_device_id), - }))} - onCreate={onCreate} - onDelete={onDelete} - onSelect={onSelect} - onLoadMoreBefore={onLoadMoreBefore} - hasMoreBefore={!onLoadMoreBefore} - onLoadMoreAfter={onLoadMoreAfter} - hasMoreAfter={!onLoadMoreAfter} - /> - </section> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx deleted file mode 100644 index 0c28027fe..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* - 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 { StateUpdater, useState } from "preact/hooks"; -import { MerchantBackend } from "../../../../declaration.js"; - -type Entity = MerchantBackend.OTP.OtpDeviceEntry; - -interface Props { - devices: Entity[]; - onDelete: (e: Entity) => void; - onSelect: (e: Entity) => void; - onCreate: () => void; - onLoadMoreBefore?: () => void; - hasMoreBefore?: boolean; - hasMoreAfter?: boolean; - onLoadMoreAfter?: () => void; -} - -export function CardTable({ - devices, - 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>OTP Devices</i18n.Translate> - </p> - <div class="card-header-icon" aria-label="more options"> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`add new devices`} - > - <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"> - {devices.length > 0 ? ( - <Table - instances={devices} - 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[]; - instances: 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({ - instances, - onLoadMoreAfter, - onDelete, - onSelect, - onLoadMoreBefore, - hasMoreAfter, - hasMoreBefore, -}: TableProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="table-container"> - {hasMoreBefore && ( - <button - class="button is-fullwidth" - data-tooltip={i18n.str`load more devices before the first one`} - onClick={onLoadMoreBefore} - > - <i18n.Translate>load newer devices</i18n.Translate> - </button> - )} - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>ID</i18n.Translate> - </th> - <th> - <i18n.Translate>Description</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - return ( - <tr key={i.otp_device_id}> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.otp_device_id} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.otp_device_id} - </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 devices from the database`} - onClick={() => onDelete(i)} - > - Delete - </button> - </div> - </td> - </tr> - ); - })} - </tbody> - </table> - {hasMoreAfter && ( - <button - class="button is-fullwidth" - data-tooltip={i18n.str`load more devices after the last one`} - onClick={onLoadMoreAfter} - > - <i18n.Translate>load older devices</i18n.Translate> - </button> - )} - </div> - ); -} - -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 devices yet, add more pressing the + sign - </i18n.Translate> - </p> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx deleted file mode 100644 index 2aae8738a..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - 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"; - -interface Props { - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onNotFound: () => VNode; - onCreate: () => void; - onSelect: (id: string) => void; -} - -export default function ListOtpDevices({ - 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 { deleteOtpDevice } = useOtpDeviceAPI(); - const result = useInstanceOtpDevices({ 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.otp_devices} - onLoadMoreBefore={ - result.isReachingStart ? result.loadMorePrev : undefined - } - onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined} - onCreate={onCreate} - onSelect={(e) => { - onSelect(e.otp_device_id); - }} - onDelete={(e: MerchantBackend.OTP.OtpDeviceEntry) => - deleteOtpDevice(e.otp_device_id) - .then(() => - setNotif({ - message: i18n.str`validator delete successfully`, - type: "SUCCESS", - }), - ) - .catch((error) => - setNotif({ - message: i18n.str`could not delete the validator`, - type: "ERROR", - description: error.message, - }), - ) - } - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx deleted file mode 100644 index d6b1d65e0..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - 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/OtpDevices/Update", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx deleted file mode 100644 index 85bb272aa..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/* - 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 { randomRfc3548Base32Key } from "@gnu-taler/taler-util"; -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 { InputSelector } from "../../../../components/form/InputSelector.js"; -import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; - -type Entity = MerchantBackend.OTP.OtpDevicePatchDetails & WithId; - -interface Props { - onUpdate: (d: Entity) => Promise<void>; - onBack?: () => void; - device: Entity; -} -const algorithms = [0, 1, 2]; -const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"]; -export function UpdatePage({ device, onUpdate, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - - const [state, setState] = useState<Partial<Entity>>(device); - const [showKey, setShowKey] = useState(false); - - 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"> - Device: <b>{device.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="otp_device_description" - label={i18n.str`Description`} - tooltip={i18n.str`Useful to identify the device physically`} - /> - <InputSelector<Entity> - name="otp_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.otp_algorithm && state.otp_algorithm > 0 ? ( - <Fragment> - <InputWithAddon<Entity> - name="otp_key" - label={i18n.str`Device key`} - readonly={state.otp_key === undefined} - inputType={showKey ? "text" : "password"} - help={ - state.otp_key === undefined - ? "Not modified" - : "Be sure to be very hard to guess or use the random generator" - } - tooltip={i18n.str`Your device need to have exactly the same value`} - fromStr={(v) => v.toUpperCase()} - addonAfterAction={() => { - setShowKey(!showKey); - }} - addonAfter={ - <span - class="icon" - onClick={() => { - setShowKey(!showKey); - }} - > - {showKey ? ( - <i class="mdi mdi-eye" /> - ) : ( - <i class="mdi mdi-eye-off" /> - )} - </span> - } - side={ - state.otp_key === undefined ? ( - <button - onClick={(e) => { - setState((s) => ({ ...s, otp_key: "" })); - }} - class="button" - > - change key - </button> - ) : ( - <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-3" - onClick={(e) => { - setState((s) => ({ - ...s, - otp_key: randomRfc3548Base32Key(), - })); - }} - > - <i18n.Translate>random</i18n.Translate> - </button> - ) - } - /> - </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> - </section> - </section> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx deleted file mode 100644 index becaf8f3a..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - 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 { AsyncButton } from "../../../../components/exception/AsyncButton.js"; -import { ProductForm } from "../../../../components/product/ProductForm.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useListener } from "../../../../hooks/listener.js"; - -type Entity = MerchantBackend.Products.ProductAddDetail & { - product_id: string; -}; - -interface Props { - onCreate: (d: Entity) => Promise<void>; - onBack?: () => void; -} - -export function CreatePage({ onCreate, onBack }: Props): VNode { - const [submitForm, addFormSubmitter] = useListener<Entity | undefined>( - (result) => { - if (result) return onCreate(result); - return Promise.reject(); - }, - ); - - const { i18n } = useTranslationContext(); - - return ( - <div> - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <ProductForm onSubscribe={addFormSubmitter} /> - - <div class="buttons is-right mt-5"> - {onBack && ( - <button class="button" onClick={onBack}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - )} - <AsyncButton - onClick={submitForm} - data-tooltip={ - !submitForm - ? i18n.str`Need to complete marked fields` - : "confirm operation" - } - disabled={!submitForm} - > - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </div> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx deleted file mode 100644 index 6b02430cc..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - 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 { h, VNode } from "preact"; -import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { Entity } from "./index.js"; -import emptyImage from "../../assets/empty.png"; - -interface Props { - entity: Entity; - onConfirm: () => void; - onCreateAnother?: () => void; -} - -export function CreatedSuccessfully({ - entity, - onConfirm, - onCreateAnother, -}: Props): VNode { - return ( - <Template onConfirm={onConfirm} onCreateAnother={onCreateAnother}> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Image</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <img src={entity.image} style={{ width: 200, height: 200 }} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Description</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <textarea class="input" readonly value={entity.description} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Price</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input class="input" readonly value={entity.price} /> - </p> - </div> - </div> - </div> - </Template> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/create/index.tsx deleted file mode 100644 index 0c30ff14c..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/create/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - 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 { AuditorBackend, MerchantBackend } from "../../../../declaration.js"; -import { useProductAPI } from "../../../../hooks/product.js"; -import { Notification } from "../../../../utils/types.js"; -import { CreatePage } from "./CreatePage.js"; - -export type Entity = MerchantBackend.Products.ProductDetail; -interface Props { - onBack?: () => void; - onConfirm: () => void; -} -export default function CreateProduct({ onConfirm, onBack }: Props): VNode { - const { createProduct } = useProductAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - - return ( - <Fragment> - <NotificationCard notification={notif} /> - - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/list/List.stories.tsx deleted file mode 100644 index c2c4d548c..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/list/List.stories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - 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 { CardTable as TestedComponent } from "./Table.js"; - -export default { - title: "Pages/Product/List", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onSelect: { action: "onSelect" }, - onDelete: { action: "onDelete" }, - onUpdate: { action: "onUpdate" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, { - instances: [ - { - id: "orderid", - description: "description1", - description_i18n: {} as any, - image: "", - price: "TESTKUDOS:10", - taxes: [], - total_lost: 10, - total_sold: 5, - total_stock: 15, - unit: "bar", - address: {}, - }, - ], -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/list/Table.tsx deleted file mode 100644 index 275f855cb..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/list/Table.tsx +++ /dev/null @@ -1,496 +0,0 @@ -/* - 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 { Amounts } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { ComponentChildren, Fragment, h, VNode } from "preact"; -import { StateUpdater, useState } from "preact/hooks"; -import emptyImage from "../../../../assets/empty.png"; -import { - FormErrors, - FormProvider, -} from "../../../../components/form/FormProvider.js"; -import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; - -type Entity = MerchantBackend.Products.ProductDetail & WithId; - -interface Props { - instances: Entity[]; - onDelete: (id: Entity) => void; - onSelect: (product: Entity) => void; - onUpdate: ( - id: string, - data: MerchantBackend.Products.ProductPatchDetail, - ) => Promise<void>; - onCreate: () => void; - selected?: boolean; -} - -export function CardTable({ - instances, - onCreate, - onSelect, - onUpdate, - onDelete, -}: Props): VNode { - const [rowSelection, rowSelectionHandler] = useState<string | undefined>( - undefined, - ); - 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-shopping" /> - </span> - <i18n.Translate>Inventory</i18n.Translate> - </p> - <div class="card-header-icon" aria-label="more options"> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`add product to inventory`} - > - <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"> - {instances.length > 0 ? ( - <Table - instances={instances} - onSelect={onSelect} - onDelete={onDelete} - onUpdate={onUpdate} - rowSelection={rowSelection} - rowSelectionHandler={rowSelectionHandler} - /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - ); -} -interface TableProps { - rowSelection: string | undefined; - instances: Entity[]; - onSelect: (id: Entity) => void; - onUpdate: ( - id: string, - data: MerchantBackend.Products.ProductPatchDetail, - ) => Promise<void>; - onDelete: (id: Entity) => void; - rowSelectionHandler: StateUpdater<string | undefined>; -} - -function Table({ - rowSelection, - rowSelectionHandler, - instances, - onSelect, - onUpdate, - onDelete, -}: TableProps): VNode { - const { i18n } = useTranslationContext(); - const [settings] = useSettings(); - return ( - <div class="table-container"> - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>Image</i18n.Translate> - </th> - <th> - <i18n.Translate>Description</i18n.Translate> - </th> - <th> - <i18n.Translate>Price per unit</i18n.Translate> - </th> - <th> - <i18n.Translate>Taxes</i18n.Translate> - </th> - <th> - <i18n.Translate>Sales</i18n.Translate> - </th> - <th> - <i18n.Translate>Stock</i18n.Translate> - </th> - <th> - <i18n.Translate>Sold</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - const restStockInfo = !i.next_restock - ? "" - : i.next_restock.t_s === "never" - ? "never" - : `restock at ${format( - new Date(i.next_restock.t_s * 1000), - dateFormatForSettings(settings), - )}`; - let stockInfo: ComponentChildren = ""; - if (i.total_stock < 0) { - stockInfo = "infinite"; - } else { - const totalStock = i.total_stock - i.total_lost - i.total_sold; - stockInfo = ( - <label title={restStockInfo}> - {totalStock} {i.unit} - </label> - ); - } - - const isFree = Amounts.isZero(Amounts.parseOrThrow(i.price)); - - return ( - <Fragment key={i.id}> - <tr key="info"> - <td - onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) - } - style={{ cursor: "pointer" }} - > - <img - src={i.image ? i.image : emptyImage} - style={{ - border: "solid black 1px", - maxHeight: "2em", - width: "auto", - height: "auto", - }} - /> - </td> - <td - class="has-tooltip-right" - data-tooltip={i.description} - onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) - } - style={{ cursor: "pointer" }} - > - {i.description.length > 30 ? i.description.substring(0, 30) + "..." : i.description} - </td> - <td - onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) - } - style={{ cursor: "pointer" }} - > - {isFree ? i18n.str`free` : `${i.price} / ${i.unit}`} - </td> - <td - onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) - } - style={{ cursor: "pointer" }} - > - {sum(i.taxes)} - </td> - <td - onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) - } - style={{ cursor: "pointer" }} - > - {difference(i.price, sum(i.taxes))} - </td> - <td - onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) - } - style={{ cursor: "pointer" }} - > - {stockInfo} - </td> - <td - onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) - } - style={{ cursor: "pointer" }} - > - <span style={{"whiteSpace":"nowrap"}}> - - {i.total_sold} {i.unit} - </span> - </td> - <td class="is-actions-cell right-sticky"> - <div class="buttons is-right"> - <span - class="has-tooltip-bottom" - data-tooltip={i18n.str`go to product update page`} - > - <button - class="button is-small is-success " - type="button" - onClick={(): void => onSelect(i)} - > - <i18n.Translate>Update</i18n.Translate> - </button> - </span> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`remove this product from the database`} - > - <button - class="button is-small is-danger" - type="button" - onClick={(): void => onDelete(i)} - > - <i18n.Translate>Delete</i18n.Translate> - </button> - </span> - </div> - </td> - </tr> - {rowSelection === i.id && ( - <tr key="form"> - <td colSpan={10}> - <FastProductUpdateForm - product={i} - onUpdate={(prod) => - onUpdate(i.id, prod).then((r) => - rowSelectionHandler(undefined), - ) - } - onCancel={() => rowSelectionHandler(undefined)} - /> - </td> - </tr> - )} - </Fragment> - ); - })} - </tbody> - </table> - </div> - ); -} - -interface FastProductUpdateFormProps { - product: Entity; - onUpdate: ( - data: MerchantBackend.Products.ProductPatchDetail, - ) => Promise<void>; - onCancel: () => void; -} -interface FastProductUpdate { - incoming: number; - lost: number; - price: string; -} -interface UpdatePrice { - price: string; -} - -function FastProductWithInfiniteStockUpdateForm({ - product, - onUpdate, - onCancel, -}: FastProductUpdateFormProps) { - const [value, valueHandler] = useState<UpdatePrice>({ price: product.price }); - const { i18n } = useTranslationContext(); - - return ( - <Fragment> - <FormProvider<FastProductUpdate> - name="added" - object={value} - valueHandler={valueHandler as any} - > - <InputCurrency<FastProductUpdate> - name="price" - label={i18n.str`Price`} - tooltip={i18n.str`update the product with new price`} - /> - </FormProvider> - - <div class="buttons is-expanded"> - - <div class="buttons mt-5"> - - <button class="button mt-5" onClick={onCancel}> - <i18n.Translate>Clone</i18n.Translate> - </button> - </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> - </Fragment> - ); -} - -function FastProductWithManagedStockUpdateForm({ - product, - onUpdate, - onCancel, -}: FastProductUpdateFormProps) { - const [value, valueHandler] = useState<FastProductUpdate>({ - incoming: 0, - lost: 0, - price: product.price, - }); - - const currentStock = - product.total_stock - product.total_sold - product.total_lost; - - const errors: FormErrors<FastProductUpdate> = { - lost: - currentStock + value.incoming < value.lost - ? `lost cannot be greater that current + incoming (max ${currentStock + value.incoming - })` - : undefined, - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - const { i18n } = useTranslationContext(); - - return ( - <Fragment> - <FormProvider<FastProductUpdate> - name="added" - errors={errors} - object={value} - valueHandler={valueHandler as any} - > - <InputNumber<FastProductUpdate> - name="incoming" - label={i18n.str`Incoming`} - tooltip={i18n.str`add more elements to the inventory`} - /> - <InputNumber<FastProductUpdate> - name="lost" - label={i18n.str`Lost`} - tooltip={i18n.str`report elements lost in the inventory`} - /> - <InputCurrency<FastProductUpdate> - name="price" - label={i18n.str`Price`} - tooltip={i18n.str`new price for the product`} - /> - </FormProvider> - - <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={ - hasErrors - ? i18n.str`the are value with errors` - : i18n.str`update product with new stock and price` - } - > - <button - class="button is-info" - disabled={hasErrors} - onClick={() => - onUpdate({ - ...product, - total_stock: product.total_stock + value.incoming, - total_lost: product.total_lost + value.lost, - price: value.price, - }) - } - > - <i18n.Translate>Confirm</i18n.Translate> - </button> - </span> - </div> - </Fragment> - ); -} - -function FastProductUpdateForm(props: FastProductUpdateFormProps) { - return props.product.total_stock === -1 ? ( - <FastProductWithInfiniteStockUpdateForm {...props} /> - ) : ( - <FastProductWithManagedStockUpdateForm {...props} /> - ); -} - -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 products yet, add more pressing the + sign - </i18n.Translate> - </p> - </div> - ); -} - -function difference(price: string, tax: number) { - if (!tax) return price; - const ps = price.split(":"); - const p = parseInt(ps[1], 10); - ps[1] = `${p - tax}`; - return ps.join(":"); -} -function sum(taxes: MerchantBackend.Tax[]) { - return taxes.reduce((p, c) => p + parseInt(c.tax.split(":")[1], 10), 0); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/list/index.tsx deleted file mode 100644 index 34b21daa2..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/list/index.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { h, VNode } 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 { - useInstanceProducts, - useProductAPI, -} from "../../../../hooks/product.js"; -import { Notification } from "../../../../utils/types.js"; -import { CardTable } from "./Table.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { ConfirmModal, DeleteModal } from "../../../../components/modal/index.js"; -import { JumpToElementById } from "../../../../components/form/JumpToElementById.js"; - -interface Props { - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onCreate: () => void; - onSelect: (id: string) => void; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; -} -export default function ProductList({ - onUnauthorized, - onLoadError, - onCreate, - onSelect, - onNotFound, -}: Props): VNode { - const result = useInstanceProducts(); - const { deleteProduct, updateProduct, getProduct } = useProductAPI(); - const [deleting, setDeleting] = - useState<MerchantBackend.Products.ProductDetail & WithId | null>(null); - 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 ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> - - <JumpToElementById - testIfExist={getProduct} - onSelect={onSelect} - description={i18n.str`jump to product with the given product ID`} - placeholder={i18n.str`product id`} - /> - - <CardTable - instances={result.data} - onCreate={onCreate} - onUpdate={(id, prod) => - updateProduct(id, prod) - .then(() => - setNotif({ - message: i18n.str`product updated successfully`, - type: "SUCCESS", - }), - ) - .catch((error) => - setNotif({ - message: i18n.str`could not update the product`, - type: "ERROR", - description: error.message, - }), - ) - } - onSelect={(product) => onSelect(product.id)} - onDelete={(prod: MerchantBackend.Products.ProductDetail & WithId) => - setDeleting(prod) - } - /> - - {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>"{deleting.description}"</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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx deleted file mode 100644 index a85b13b8b..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - 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/Product/Update", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const WithManagedStock = createExample(TestedComponent, { - product: { - product_id: "20102-ASDAS-QWE", - description: "description1", - description_i18n: {} as any, - image: "", - price: "TESTKUDOS:10", - taxes: [], - total_lost: 10, - total_sold: 5, - total_stock: 15, - unit: "bar", - address: {}, - }, -}); - -export const WithInfiniteStock = createExample(TestedComponent, { - product: { - product_id: "20102-ASDAS-QWE", - description: "description1", - description_i18n: {} as any, - image: "", - price: "TESTKUDOS:10", - taxes: [], - total_lost: 10, - total_sold: 5, - total_stock: -1, - unit: "bar", - address: {}, - }, -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx deleted file mode 100644 index 97715171e..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - 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 { AsyncButton } from "../../../../components/exception/AsyncButton.js"; -import { ProductForm } from "../../../../components/product/ProductForm.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useListener } from "../../../../hooks/listener.js"; - -type Entity = MerchantBackend.Products.ProductDetail & { product_id: string }; - -interface Props { - onUpdate: (d: Entity) => Promise<void>; - onBack?: () => void; - product: Entity; -} - -export function UpdatePage({ product, onUpdate, onBack }: Props): VNode { - const [submitForm, addFormSubmitter] = useListener<Entity | undefined>( - (result) => { - if (result) return onUpdate(result); - return Promise.resolve(); - }, - ); - - const { i18n } = useTranslationContext(); - - 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"> - <i18n.Translate>Product id:</i18n.Translate> - <b>{product.product_id}</b> - </span> - </div> - </div> - </div> - </div> - </section> - <hr /> - - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <ProductForm - initial={product} - onSubscribe={addFormSubmitter} - alreadyExist - /> - - <div class="buttons is-right mt-5"> - {onBack && ( - <button class="button" onClick={onBack}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - )} - <AsyncButton - onClick={submitForm} - data-tooltip={ - !submitForm - ? i18n.str`Need to complete marked fields` - : "confirm operation" - } - disabled={!submitForm} - > - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </div> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/Create.stories.tsx deleted file mode 100644 index 5542c028a..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/Create.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - 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/Reserve/Create", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, {}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx deleted file mode 100644 index e46941b6d..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx +++ /dev/null @@ -1,277 +0,0 @@ -/* - 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 { HttpError, RequestError, useApiContext, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { StateUpdater, useEffect, 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 { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { - PAYTO_WIRE_METHOD_LOOKUP, - URL_REGEX, -} from "../../../../utils/constants.js"; -import { useBackendBaseRequest } from "../../../../hooks/backend.js"; -import { parsePaytoUri } from "@gnu-taler/taler-util"; - -type Entity = MerchantBackend.Rewards.ReserveCreateRequest; - -interface Props { - onCreate: (d: Entity) => Promise<void>; - onBack?: () => void; -} - -enum Steps { - EXCHANGE, - WIRE_METHOD, -} - -interface ViewProps { - step: Steps; - setCurrentStep: (s: Steps) => void; - reserve: Partial<Entity>; - onBack?: () => void; - submitForm: () => Promise<void>; - setReserve: StateUpdater<Partial<Entity>>; -} -function ViewStep({ - step, - setCurrentStep, - reserve, - onBack, - submitForm, - setReserve, -}: ViewProps): VNode { - const { i18n } = useTranslationContext(); - const {request} = useApiContext() - const [wireMethods, setWireMethods] = useState<Array<string>>([]); - const [exchangeQueryError, setExchangeQueryError] = useState< - string | undefined - >(undefined); - - useEffect(() => { - setExchangeQueryError(undefined); - }, [reserve.exchange_url]); - - switch (step) { - case Steps.EXCHANGE: { - const errors: FormErrors<Entity> = { - initial_balance: !reserve.initial_balance - ? "cannot be empty" - : !(parseInt(reserve.initial_balance.split(":")[1], 10) > 0) - ? i18n.str`it should be greater than 0` - : undefined, - exchange_url: !reserve.exchange_url - ? i18n.str`cannot be empty` - : !URL_REGEX.test(reserve.exchange_url) - ? i18n.str`must be a valid URL` - : !exchangeQueryError - ? undefined - : exchangeQueryError, - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - return ( - <Fragment> - <FormProvider<Entity> - object={reserve} - errors={errors} - valueHandler={setReserve} - > - <InputCurrency<Entity> - name="initial_balance" - label={i18n.str`Initial balance`} - tooltip={i18n.str`balance prior to deposit`} - /> - <Input<Entity> - name="exchange_url" - label={i18n.str`Exchange URL`} - tooltip={i18n.str`URL of exchange`} - /> - </FormProvider> - - <div class="buttons is-right mt-5"> - {onBack && ( - <button class="button" onClick={onBack}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - )} - <AsyncButton - class="has-tooltip-left" - onClick={() => { - if (!reserve.exchange_url) { - return Promise.resolve(); - } - - return request<any>(reserve.exchange_url, "keys") - .then((r) => { - console.log(r) - if (r.loading) return; - if (r.ok) { - const wireMethods = r.data.accounts.map((a: any) => { - const p = parsePaytoUri(a.payto_uri); - const r = p?.targetType - return r - }).filter((x:any) => !!x); - setWireMethods(Array.from(new Set(wireMethods))); - } - setCurrentStep(Steps.WIRE_METHOD); - return; - }) - .catch((r: RequestError<{}>) => { - console.log(r.cause) - setExchangeQueryError(r.cause.message); - }); - }} - data-tooltip={ - hasErrors - ? i18n.str`Need to complete marked fields` - : "confirm operation" - } - disabled={hasErrors} - > - <i18n.Translate>Next</i18n.Translate> - </AsyncButton> - </div> - </Fragment> - ); - } - - case Steps.WIRE_METHOD: { - const errors: FormErrors<Entity> = { - wire_method: !reserve.wire_method - ? i18n.str`cannot be empty` - : undefined, - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - return ( - <Fragment> - <FormProvider<Entity> - object={reserve} - errors={errors} - valueHandler={setReserve} - > - <InputCurrency<Entity> - name="initial_balance" - label={i18n.str`Initial balance`} - tooltip={i18n.str`balance prior to deposit`} - readonly - /> - <Input<Entity> - name="exchange_url" - label={i18n.str`Exchange URL`} - tooltip={i18n.str`URL of exchange`} - readonly - /> - <InputSelector<Entity> - name="wire_method" - label={i18n.str`Wire method`} - tooltip={i18n.str`method to use for wire transfer`} - values={wireMethods} - placeholder={i18n.str`Select one wire method`} - /> - </FormProvider> - <div class="buttons is-right mt-5"> - {onBack && ( - <button - class="button" - onClick={() => setCurrentStep(Steps.EXCHANGE)} - > - <i18n.Translate>Back</i18n.Translate> - </button> - )} - <AsyncButton - onClick={submitForm} - data-tooltip={ - hasErrors - ? i18n.str`Need to complete marked fields` - : "confirm operation" - } - disabled={hasErrors} - > - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </div> - </Fragment> - ); - } - } -} - -export function CreatePage({ onCreate, onBack }: Props): VNode { - const [reserve, setReserve] = useState<Partial<Entity>>({}); - - const submitForm = () => { - return onCreate(reserve as Entity); - }; - - const [currentStep, setCurrentStep] = useState(Steps.EXCHANGE); - - return ( - <div> - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <div class="tabs is-toggle is-fullwidth is-small"> - <ul> - <li class={currentStep === Steps.EXCHANGE ? "is-active" : ""}> - <a style={{ cursor: "initial" }}> - <span>Step 1: Specify exchange</span> - </a> - </li> - <li - class={currentStep === Steps.WIRE_METHOD ? "is-active" : ""} - > - <a style={{ cursor: "initial" }}> - <span>Step 2: Select wire method</span> - </a> - </li> - </ul> - </div> - - <ViewStep - step={currentStep} - reserve={reserve} - setCurrentStep={setCurrentStep} - setReserve={setReserve} - submitForm={submitForm} - onBack={onBack} - /> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.stories.tsx deleted file mode 100644 index 445ca3ef0..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.stories.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/* - 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 { CreatedSuccessfully as TestedComponent } from "./CreatedSuccessfully.js"; -import * as tests from "@gnu-taler/web-util/testing"; - -export default { - title: "Pages/Reserve/CreatedSuccessfully", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onBack: { action: "onBack" }, - }, -}; - -export const OneBankAccount = tests.createExample(TestedComponent, { - entity: { - request: { - exchange_url: "http://exchange.taler/", - initial_balance: "TESTKUDOS:1", - wire_method: "x-taler-bank", - }, - response: { - accounts: [ - { - payto_uri: "payto://x-taler-bank/bank.taler:8080/exchange_account", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "asd", - conversion_url: "", - }, - ], - reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS", - }, - }, -}); - -export const ThreeBankAccount = tests.createExample(TestedComponent, { - entity: { - request: { - exchange_url: "http://exchange.taler/", - initial_balance: "TESTKUDOS:1", - wire_method: "x-taler-bank", - }, - response: { - accounts: [ - { - payto_uri: "payto://x-taler-bank/bank.taler:8080/exchange_account", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "asd", - conversion_url: "", - }, - { - payto_uri: "payto://x-taler-bank/bank1.taler:8080/asd", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "asd", - conversion_url: "", - }, - { - payto_uri: "payto://x-taler-bank/bank2.taler:8080/qwe", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "asd", - conversion_url: "", - }, - ], - reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS", - }, - }, -}); - -export const NoBankAccount = tests.createExample(TestedComponent, { - entity: { - request: { - exchange_url: "http://exchange.taler/", - initial_balance: "TESTKUDOS:1", - wire_method: "x-taler-bank", - }, - response: { - accounts: [ - { - payto_uri: "payo://x-talr-bank/bank.taler:8080/exchange_account", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "asd", - conversion_url: "", - }, - { - payto_uri: "payto://x-taler-bank", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "asd", - conversion_url: "", - }, - ], - reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS", - }, - }, -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx deleted file mode 100644 index 1d512c843..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx +++ /dev/null @@ -1,190 +0,0 @@ -/* - 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 { parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { QR } from "../../../../components/exception/QR.js"; -import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { MerchantBackend, WireAccount } from "../../../../declaration.js"; - -type Entity = { - request: MerchantBackend.Rewards.ReserveCreateRequest; - response: MerchantBackend.Rewards.ReserveCreateConfirmation; -}; - -interface Props { - entity: Entity; - onConfirm: () => void; - onCreateAnother?: () => void; -} - -function isNotUndefined<X>(x: X | undefined): x is X { - return !!x; -} - -export function CreatedSuccessfully({ - entity, - onConfirm, - onCreateAnother, -}: Props): VNode { - const { i18n } = useTranslationContext(); - return ( - <Template onConfirm={onConfirm} onCreateAnother={onCreateAnother}> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Amount</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input - readonly - class="input" - value={entity.request.initial_balance} - /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Wire transfer subject</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input - class="input" - readonly - value={entity.response.reserve_pub} - /> - </p> - </div> - </div> - </div> - <ShowAccountsOfReserveAsQRWithLink - accounts={entity.response.accounts ?? []} - message={entity.response.reserve_pub} - amount={entity.request.initial_balance} - /> - </Template> - ); -} - -export function ShowAccountsOfReserveAsQRWithLink({ - accounts, - message, - amount, -}: { - accounts: WireAccount[]; - message: string; - amount: string; -}): VNode { - const { i18n } = useTranslationContext(); - const accountsInfo = !accounts - ? [] - : accounts - .map((acc) => { - const p = parsePaytoUri(acc.payto_uri); - if (p) { - p.params["message"] = message; - p.params["amount"] = amount; - } - return p; - }) - .filter(isNotUndefined); - - const links = accountsInfo.map((a) => stringifyPaytoUri(a)); - - if (links.length === 0) { - return ( - <Fragment> - <p class="is-size-5"> - The reserve have invalid accounts. List of invalid payto URIs below: - </p> - <ul> - {accounts.map((a, idx) => { - return <li key={idx}>{a.payto_uri}</li>; - })} - </ul> - </Fragment> - ); - } - - if (links.length === 1) { - return ( - <Fragment> - <p class="is-size-5"> - <i18n.Translate> - To complete the setup of the reserve, you must now initiate a wire - transfer using the given wire transfer subject and crediting the - specified amount to the indicated account of the exchange. - </i18n.Translate> - </p> - <p style={{ margin: 10 }}> - <b>Exchange bank account</b> - </p> - <QR text={links[0]} /> - <p class="is-size-5"> - <i18n.Translate> - If your system supports RFC 8905, you can do this by opening this - URI: - </i18n.Translate> - </p> - <pre> - <a target="_blank" rel="noreferrer" href={links[0]}> - {links[0]} - </a> - </pre> - </Fragment> - ); - } - - return ( - <div> - <p class="is-size-5"> - <i18n.Translate> - To complete the setup of the reserve, you must now initiate a wire - transfer using the given wire transfer subject and crediting the - specified amount to one of the indicated account of the exchange. - </i18n.Translate> - </p> - - <p style={{ margin: 10 }}> - <b>Exchange bank accounts</b> - </p> - <p class="is-size-5"> - <i18n.Translate> - If your system supports RFC 8905, you can do this by clicking on the - URI below the QR code: - </i18n.Translate> - </p> - {links.map((link) => { - return ( - <Fragment> - <QR text={link} /> - <pre> - <a target="_blank" rel="noreferrer" href={link}> - {link} - </a> - </pre> - </Fragment> - ); - })} - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/index.tsx deleted file mode 100644 index 4bbaf1459..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - 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 { useReservesAPI } from "../../../../hooks/reserves.js"; -import { Notification } from "../../../../utils/types.js"; -import { CreatedSuccessfully } from "./CreatedSuccessfully.js"; -import { CreatePage } from "./CreatePage.js"; -interface Props { - onBack: () => void; - onConfirm: () => void; -} -export default function CreateReserve({ onBack, onConfirm }: Props): VNode { - const { createReserve } = useReservesAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - - const [createdOk, setCreatedOk] = useState< - | { - request: MerchantBackend.Rewards.ReserveCreateRequest; - response: MerchantBackend.Rewards.ReserveCreateConfirmation; - } - | undefined - >(undefined); - - if (createdOk) { - return <CreatedSuccessfully entity={createdOk} onConfirm={onConfirm} />; - } - - return ( - <Fragment> - <NotificationCard notification={notif} /> - <CreatePage - onBack={onBack} - onCreate={(request: MerchantBackend.Rewards.ReserveCreateRequest) => { - return createReserve(request) - .then((r) => setCreatedOk({ request, response: r.data })) - .catch((error) => { - setNotif({ - message: i18n.str`could not create reserve`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx deleted file mode 100644 index d8840eeac..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx +++ /dev/null @@ -1,266 +0,0 @@ -/* - 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 { - Amounts, - parsePaytoUri, - stringifyPaytoUri, -} from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { QR } from "../../../../components/exception/QR.js"; -import { FormProvider } from "../../../../components/form/FormProvider.js"; -import { Input } from "../../../../components/form/Input.js"; -import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputDate } from "../../../../components/form/InputDate.js"; -import { TextField } from "../../../../components/form/TextField.js"; -import { SimpleModal } from "../../../../components/modal/index.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useRewardDetails } from "../../../../hooks/reserves.js"; -import { RewardInfo } from "./RewardInfo.js"; -import { ShowAccountsOfReserveAsQRWithLink } from "../create/CreatedSuccessfully.js"; -import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; - -type Entity = MerchantBackend.Rewards.ReserveDetail; -type CT = MerchantBackend.ContractTerms; - -interface Props { - onBack: () => void; - selected: Entity; - id: string; -} - -export function DetailPage({ id, selected, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - const didExchangeAckTransfer = Amounts.isNonZero( - Amounts.parseOrThrow(selected.exchange_initial_amount), - ); - - return ( - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <div class="section main-section"> - <FormProvider object={{ ...selected, id }} valueHandler={null}> - <InputDate<Entity> - name="creation_time" - label={i18n.str`Created at`} - readonly - /> - <InputDate<Entity> - name="expiration_time" - label={i18n.str`Valid until`} - readonly - /> - <InputCurrency<Entity> - name="merchant_initial_amount" - label={i18n.str`Created balance`} - readonly - /> - <TextField<Entity> - name="exchange_url" - label={i18n.str`Exchange URL`} - readonly - > - <a target="_blank" rel="noreferrer" href={selected.exchange_url}> - {selected.exchange_url} - </a> - </TextField> - - {didExchangeAckTransfer && ( - <Fragment> - <InputCurrency<Entity> - name="exchange_initial_amount" - label={i18n.str`Exchange balance`} - readonly - /> - <InputCurrency<Entity> - name="pickup_amount" - label={i18n.str`Picked up`} - readonly - /> - <InputCurrency<Entity> - name="committed_amount" - label={i18n.str`Committed`} - readonly - /> - </Fragment> - )} - <Input name="id" label={i18n.str`Subject`} readonly /> - </FormProvider> - - {didExchangeAckTransfer ? ( - <Fragment> - <div class="card has-table"> - <header class="card-header"> - <p class="card-header-title"> - <span class="icon"> - <i class="mdi mdi-cash-register" /> - </span> - <i18n.Translate>Rewards</i18n.Translate> - </p> - </header> - <div class="card-content"> - <div class="b-table has-pagination"> - <div class="table-wrapper has-mobile-cards"> - {selected.rewards && selected.rewards.length > 0 ? ( - <Table rewards={selected.rewards} /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - </Fragment> - ) : selected.accounts ? ( - <ShowAccountsOfReserveAsQRWithLink - accounts={selected.accounts} - amount={selected.merchant_initial_amount} - message={id} - /> - ) : undefined} - - <div class="buttons is-right mt-5"> - <button class="button" onClick={onBack}> - <i18n.Translate>Back</i18n.Translate> - </button> - </div> - </div> - </div> - <div class="column" /> - </div> - ); -} - -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> - No reward has been authorized from this reserve - </i18n.Translate> - </p> - </div> - ); -} - -interface TableProps { - rewards: MerchantBackend.Rewards.RewardStatusEntry[]; -} - -function Table({ rewards }: TableProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="table-container"> - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>Authorized</i18n.Translate> - </th> - <th> - <i18n.Translate>Picked up</i18n.Translate> - </th> - <th> - <i18n.Translate>Reason</i18n.Translate> - </th> - <th> - <i18n.Translate>Expiration</i18n.Translate> - </th> - </tr> - </thead> - <tbody> - {rewards.map((t, i) => { - return <RewardRow id={t.reward_id} key={i} entry={t} />; - })} - </tbody> - </table> - </div> - ); -} - -function RewardRow({ - id, - entry, -}: { - id: string; - entry: MerchantBackend.Rewards.RewardStatusEntry; -}) { - const [selected, setSelected] = useState(false); - const result = useRewardDetails(id); - const [settings] = useSettings(); - if (result.loading) { - return ( - <tr> - <td>...</td> - <td>...</td> - <td>...</td> - <td>...</td> - </tr> - ); - } - if (!result.ok) { - return ( - <tr> - <td>...</td> {/* authorized */} - <td>{entry.total_amount}</td> - <td>{entry.reason}</td> - <td>...</td> {/* expired */} - </tr> - ); - } - const info = result.data; - function onSelect() { - setSelected(true); - } - return ( - <Fragment> - {selected && ( - <SimpleModal - description="reward" - active - onCancel={() => setSelected(false)} - > - <RewardInfo id={id} amount={info.total_authorized} entity={info} /> - </SimpleModal> - )} - <tr> - <td onClick={onSelect}>{info.total_authorized}</td> - <td onClick={onSelect}>{info.total_picked_up}</td> - <td onClick={onSelect}>{info.reason}</td> - <td onClick={onSelect}> - {info.expiration.t_s === "never" - ? "never" - : format(info.expiration.t_s * 1000, datetimeFormatForSettings(settings))} - </td> - </tr> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx deleted file mode 100644 index 41c715f20..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/* - 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 { DetailPage as TestedComponent } from "./DetailPage.js"; - -export default { - title: "Pages/Reserve/Detail", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Funded = createExample(TestedComponent, { - id: "THISISTHERESERVEID", - selected: { - active: true, - committed_amount: "TESTKUDOS:10", - creation_time: { - t_s: new Date().getTime() / 1000, - }, - exchange_initial_amount: "TESTKUDOS:10", - expiration_time: { - t_s: new Date().getTime() / 1000, - }, - merchant_initial_amount: "TESTKUDOS:10", - pickup_amount: "TESTKUDOS:10", - accounts: [ - { - payto_uri: "payto://x-taler-bank/bank.taler:8080/account", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "", - }, - ], - exchange_url: "http://exchange.taler/", - }, -}); - -export const NotYetFunded = createExample(TestedComponent, { - id: "THISISTHERESERVEID", - selected: { - active: true, - committed_amount: "TESTKUDOS:10", - creation_time: { - t_s: new Date().getTime() / 1000, - }, - exchange_initial_amount: "TESTKUDOS:0", - expiration_time: { - t_s: new Date().getTime() / 1000, - }, - merchant_initial_amount: "TESTKUDOS:10", - pickup_amount: "TESTKUDOS:10", - accounts: [ - { - payto_uri: "payto://x-taler-bank/bank.taler:8080/account", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "", - }, - ], - exchange_url: "http://exchange.taler/", - }, -}); - -export const FundedWithEmptyRewards = createExample(TestedComponent, { - id: "THISISTHERESERVEID", - selected: { - active: true, - committed_amount: "TESTKUDOS:10", - creation_time: { - t_s: new Date().getTime() / 1000, - }, - exchange_initial_amount: "TESTKUDOS:10", - expiration_time: { - t_s: new Date().getTime() / 1000, - }, - merchant_initial_amount: "TESTKUDOS:10", - pickup_amount: "TESTKUDOS:10", - accounts: [ - { - payto_uri: "payto://x-taler-bank/bank.taler:8080/account", - credit_restrictions: [], - debit_restrictions: [], - master_sig: "", - }, - ], - exchange_url: "http://exchange.taler/", - rewards: [ - { - reason: "asdasd", - reward_id: "123", - total_amount: "TESTKUDOS:1", - }, - ], - }, -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx deleted file mode 100644 index 491028695..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/* - 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 { format } from "date-fns"; -import { Fragment, h, VNode } from "preact"; -import { useBackendContext } from "../../../../context/backend.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { - datetimeFormatForSettings, - useSettings, -} from "../../../../hooks/useSettings.js"; - -type Entity = MerchantBackend.Rewards.RewardDetails; - -interface Props { - id: string; - entity: Entity; - amount: string; -} - -export function RewardInfo({ - id: merchantRewardId, - amount, - entity, -}: Props): VNode { - const { url: backendURL } = useBackendContext(); - const [settings] = useSettings(); - const rewardURL = "not-supported"; - return ( - <Fragment> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Amount</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input readonly class="input" value={amount} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">URL</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field" style={{ overflowWrap: "anywhere" }}> - <p class="control"> - <a target="_blank" rel="noreferrer" href={rewardURL}> - {rewardURL} - </a> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Valid until</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input - class="input" - readonly - value={ - !entity.expiration || entity.expiration.t_s === "never" - ? "never" - : format( - entity.expiration.t_s * 1000, - datetimeFormatForSettings(settings), - ) - } - /> - </p> - </div> - </div> - </div> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/index.tsx deleted file mode 100644 index 8e2a74529..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - 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 { ErrorType, HttpError } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { Loading } from "../../../../components/exception/loading.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useReserveDetails } from "../../../../hooks/reserves.js"; -import { DetailPage } from "./DetailPage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -interface Props { - rid: string; - - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onNotFound: () => VNode; - onDelete: () => void; - onBack: () => void; -} -export default function DetailReserve({ - rid, - onUnauthorized, - onLoadError, - onNotFound, - onBack, - onDelete, -}: Props): VNode { - const result = useReserveDetails(rid); - - 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> - <DetailPage selected={result.data} onBack={onBack} id={rid} /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx deleted file mode 100644 index e205ee621..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - 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 * as yup from "yup"; -import { - FormErrors, - FormProvider, -} from "../../../../components/form/FormProvider.js"; -import { Input } from "../../../../components/form/Input.js"; -import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { - ConfirmModal, - ContinueModal, -} from "../../../../components/modal/index.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { AuthorizeRewardSchema } from "../../../../schemas/index.js"; -import { CreatedSuccessfully } from "./CreatedSuccessfully.js"; - -interface AuthorizeRewardModalProps { - onCancel: () => void; - onConfirm: (value: MerchantBackend.Rewards.RewardCreateRequest) => void; - rewardAuthorized?: { - response: MerchantBackend.Rewards.RewardCreateConfirmation; - request: MerchantBackend.Rewards.RewardCreateRequest; - }; -} - -export function AuthorizeRewardModal({ - onCancel, - onConfirm, - rewardAuthorized, -}: AuthorizeRewardModalProps): VNode { - // const result = useOrderDetails(id) - type State = MerchantBackend.Rewards.RewardCreateRequest; - const [form, setValue] = useState<Partial<State>>({}); - const { i18n } = useTranslationContext(); - - // const [errors, setErrors] = useState<FormErrors<State>>({}) - let errors: FormErrors<State> = {}; - try { - AuthorizeRewardSchema.validateSync(form, { abortEarly: false }); - } catch (err) { - if (err instanceof yup.ValidationError) { - const yupErrors = err.inner as any[]; - errors = yupErrors.reduce( - (prev, cur) => - !cur.path ? prev : { ...prev, [cur.path]: cur.message }, - {}, - ); - } - } - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const validateAndConfirm = () => { - onConfirm(form as State); - }; - if (rewardAuthorized) { - return ( - <ContinueModal description="reward" active onConfirm={onCancel}> - <CreatedSuccessfully - entity={rewardAuthorized.response} - request={rewardAuthorized.request} - onConfirm={onCancel} - /> - </ContinueModal> - ); - } - - return ( - <ConfirmModal - description="New reward" - active - onCancel={onCancel} - disabled={hasErrors} - onConfirm={validateAndConfirm} - > - <FormProvider<State> - errors={errors} - object={form} - valueHandler={setValue} - > - <InputCurrency<State> - name="amount" - label={i18n.str`Amount`} - tooltip={i18n.str`amount of reward`} - /> - <Input<State> - name="justification" - label={i18n.str`Justification`} - inputType="multiline" - tooltip={i18n.str`reason for the reward`} - /> - <Input<State> - name="next_url" - label={i18n.str`URL after reward`} - tooltip={i18n.str`URL to visit after reward payment`} - /> - </FormProvider> - </ConfirmModal> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx deleted file mode 100644 index b78236bc7..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - 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 { format } from "date-fns"; -import { Fragment, h, VNode } from "preact"; -import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; - -type Entity = MerchantBackend.Rewards.RewardCreateConfirmation; - -interface Props { - entity: Entity; - request: MerchantBackend.Rewards.RewardCreateRequest; - onConfirm: () => void; - onCreateAnother?: () => void; -} - -export function CreatedSuccessfully({ - request, - entity, - onConfirm, - onCreateAnother, -}: Props): VNode { - const [settings] = useSettings(); - return ( - <Fragment> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Amount</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input readonly class="input" value={request.amount} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Justification</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input readonly class="input" value={request.justification} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">URL</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input readonly class="input" value={entity.reward_status_url} /> - </p> - </div> - </div> - </div> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label">Valid until</label> - </div> - <div class="field-body is-flex-grow-3"> - <div class="field"> - <p class="control"> - <input - class="input" - readonly - value={ - !entity.reward_expiration || - entity.reward_expiration.t_s === "never" - ? "never" - : format( - entity.reward_expiration.t_s * 1000, - datetimeFormatForSettings(settings), - ) - } - /> - </p> - </div> - </div> - </div> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx deleted file mode 100644 index b070bbde3..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - 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 { CardTable as TestedComponent } from "./Table.js"; - -export default { - title: "Pages/Reserve/List", - component: TestedComponent, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const AllFunded = createExample(TestedComponent, { - instances: [ - { - id: "reseverId", - active: true, - committed_amount: "TESTKUDOS:10", - creation_time: { - t_s: new Date().getTime() / 1000, - }, - exchange_initial_amount: "TESTKUDOS:10", - expiration_time: { - t_s: new Date().getTime() / 1000, - }, - merchant_initial_amount: "TESTKUDOS:10", - pickup_amount: "TESTKUDOS:10", - reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS", - }, - { - id: "reseverId2", - active: true, - committed_amount: "TESTKUDOS:13", - creation_time: { - t_s: new Date().getTime() / 1000, - }, - exchange_initial_amount: "TESTKUDOS:10", - expiration_time: { - t_s: new Date().getTime() / 1000, - }, - merchant_initial_amount: "TESTKUDOS:10", - pickup_amount: "TESTKUDOS:10", - reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS", - }, - ], -}); - -export const Empty = createExample(TestedComponent, { - instances: [], -}); - -export const OneNotYetFunded = createExample(TestedComponent, { - instances: [ - { - id: "reseverId", - active: true, - committed_amount: "TESTKUDOS:0", - creation_time: { - t_s: new Date().getTime() / 1000, - }, - exchange_initial_amount: "TESTKUDOS:0", - expiration_time: { - t_s: new Date().getTime() / 1000, - }, - merchant_initial_amount: "TESTKUDOS:10", - pickup_amount: "TESTKUDOS:10", - reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS", - }, - ], -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/Table.tsx deleted file mode 100644 index 795e7ec82..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/Table.tsx +++ /dev/null @@ -1,320 +0,0 @@ -/* - 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 { format } from "date-fns"; -import { Fragment, h, VNode } from "preact"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; - -type Entity = MerchantBackend.Rewards.ReserveStatusEntry & WithId; - -interface Props { - instances: Entity[]; - onNewReward: (id: Entity) => void; - onSelect: (id: Entity) => void; - onDelete: (id: Entity) => void; - onCreate: () => void; -} - -export function CardTable({ - instances, - onCreate, - onSelect, - onNewReward, - onDelete, -}: Props): VNode { - const [withoutFunds, withFunds] = instances.reduce((prev, current) => { - const amount = current.exchange_initial_amount; - if (amount.endsWith(":0")) { - prev[0] = prev[0].concat(current); - } else { - prev[1] = prev[1].concat(current); - } - return prev; - }, new Array<Array<Entity>>([], [])); - - const { i18n } = useTranslationContext(); - - return ( - <Fragment> - {withoutFunds.length > 0 && ( - <div class="card has-table"> - <header class="card-header"> - <p class="card-header-title"> - <span class="icon"> - <i class="mdi mdi-cash" /> - </span> - <i18n.Translate>Reserves not yet funded</i18n.Translate> - </p> - </header> - <div class="card-content"> - <div class="b-table has-pagination"> - <div class="table-wrapper has-mobile-cards"> - <TableWithoutFund - instances={withoutFunds} - onNewReward={onNewReward} - onSelect={onSelect} - onDelete={onDelete} - /> - </div> - </div> - </div> - </div> - )} - - <div class="card has-table"> - <header class="card-header"> - <p class="card-header-title"> - <span class="icon"> - <i class="mdi mdi-cash" /> - </span> - <i18n.Translate>Reserves ready</i18n.Translate> - </p> - <div class="card-header-icon" aria-label="more options" /> - <div class="card-header-icon" aria-label="more options"> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`add new reserve`} - > - <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"> - {withFunds.length > 0 ? ( - <Table - instances={withFunds} - onNewReward={onNewReward} - onSelect={onSelect} - onDelete={onDelete} - /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - </Fragment> - ); -} -interface TableProps { - instances: Entity[]; - onNewReward: (id: Entity) => void; - onDelete: (id: Entity) => void; - onSelect: (id: Entity) => void; -} - -function Table({ instances, onNewReward, onSelect, onDelete }: TableProps): VNode { - const { i18n } = useTranslationContext(); - const [settings] = useSettings(); - return ( - <div class="table-container"> - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>Created at</i18n.Translate> - </th> - <th> - <i18n.Translate>Expires at</i18n.Translate> - </th> - <th> - <i18n.Translate>Initial</i18n.Translate> - </th> - <th> - <i18n.Translate>Picked up</i18n.Translate> - </th> - <th> - <i18n.Translate>Committed</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - return ( - <tr key={i.id}> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.creation_time.t_s === "never" - ? "never" - : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.expiration_time.t_s === "never" - ? "never" - : format( - i.expiration_time.t_s * 1000, - datetimeFormatForSettings(settings), - )} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.exchange_initial_amount} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.pickup_amount} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.committed_amount} - </td> - <td class="is-actions-cell right-sticky"> - <div class="buttons is-right"> - <button - class="button is-small is-danger has-tooltip-left" - data-tooltip={i18n.str`delete selected reserve from the database`} - type="button" - onClick={(): void => onDelete(i)} - > - Delete - </button> - <button - class="button is-small is-info has-tooltip-left" - data-tooltip={i18n.str`authorize new reward from selected reserve`} - type="button" - onClick={(): void => onNewReward(i)} - > - New Reward - </button> - </div> - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - ); -} - -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 ready reserves yet, add more pressing the + sign or fund - them - </i18n.Translate> - </p> - </div> - ); -} - -function TableWithoutFund({ - instances, - onSelect, - onDelete, -}: TableProps): VNode { - const { i18n } = useTranslationContext(); - const [settings] = useSettings(); - return ( - <div class="table-container"> - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>Created at</i18n.Translate> - </th> - <th> - <i18n.Translate>Expires at</i18n.Translate> - </th> - <th> - <i18n.Translate>Expected Balance</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - return ( - <tr key={i.id}> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.creation_time.t_s === "never" - ? "never" - : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.expiration_time.t_s === "never" - ? "never" - : format( - i.expiration_time.t_s * 1000, - datetimeFormatForSettings(settings), - )} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.merchant_initial_amount} - </td> - <td class="is-actions-cell right-sticky"> - <div class="buttons is-right"> - <button - class="button is-small is-danger jb-modal has-tooltip-left" - type="button" - data-tooltip={i18n.str`delete selected reserve from the database`} - onClick={(): void => onDelete(i)} - > - Delete - </button> - </div> - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/index.tsx deleted file mode 100644 index b26ff0000..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/index.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { h, VNode } 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 { - useInstanceReserves, - useReservesAPI, -} from "../../../../hooks/reserves.js"; -import { Notification } from "../../../../utils/types.js"; -import { AuthorizeRewardModal } from "./AutorizeRewardModal.js"; -import { CardTable } from "./Table.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { ConfirmModal } from "../../../../components/modal/index.js"; - -interface Props { - onUnauthorized: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onSelect: (id: string) => void; - onNotFound: () => VNode; - onCreate: () => void; -} - -interface RewardConfirmation { - response: MerchantBackend.Rewards.RewardCreateConfirmation; - request: MerchantBackend.Rewards.RewardCreateRequest; -} - -export default function ListRewards({ - onUnauthorized, - onLoadError, - onNotFound, - onSelect, - onCreate, -}: Props): VNode { - const result = useInstanceReserves(); - const { deleteReserve, authorizeRewardReserve } = useReservesAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - const [reserveForReward, setReserveForReward] = useState<string | undefined>( - undefined, - ); - const [deleting, setDeleting] = - useState<MerchantBackend.Rewards.ReserveStatusEntry | null>(null); - const [rewardAuthorized, setRewardAuthorized] = useState< - RewardConfirmation | undefined - >(undefined); - - 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 ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> - - {reserveForReward && ( - <AuthorizeRewardModal - onCancel={() => { - setReserveForReward(undefined); - setRewardAuthorized(undefined); - }} - rewardAuthorized={rewardAuthorized} - onConfirm={async (request) => { - try { - const response = await authorizeRewardReserve( - reserveForReward, - request, - ); - setRewardAuthorized({ - request, - response: response.data, - }); - } catch (error) { - setNotif({ - message: i18n.str`could not create the reward`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - setReserveForReward(undefined); - } - }} - /> - )} - - <CardTable - instances={result.data.reserves - .filter((r) => r.active) - .map((o) => ({ ...o, id: o.reserve_pub }))} - onCreate={onCreate} - onDelete={(reserve) => { - setDeleting(reserve) - }} - onSelect={(reserve) => onSelect(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>"{deleting.merchant_initial_amount}"</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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx deleted file mode 100644 index c9d17ea3b..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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/Templates/Create", - component: TestedComponent, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx deleted file mode 100644 index 502cfea08..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx +++ /dev/null @@ -1,270 +0,0 @@ -/* - 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 { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } 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 { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputDuration } from "../../../../components/form/InputDuration.js"; -import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js"; -import { InputTab } from "../../../../components/form/InputTab.js"; -import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; -import { useBackendContext } from "../../../../context/backend.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; -import { undefinedIfEmpty } from "../../../../utils/table.js"; - -enum Steps { - BOTH_FIXED, - FIXED_PRICE, - FIXED_SUMMARY, - NON_FIXED, -} - -type Entity = MerchantBackend.Template.TemplateAddDetails & { type: Steps }; - -interface Props { - onCreate: (d: Entity) => Promise<void>; - onBack?: () => void; -} - -export function CreatePage({ onCreate, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext(); - const devices = useInstanceOtpDevices(); - - const [state, setState] = useState<Partial<Entity>>({ - template_contract: { - minimum_age: 0, - pay_duration: { - d_us: 1000 * 1000 * 60 * 30, //30 min - }, - }, - type: Steps.NON_FIXED, - }); - - const parsedPrice = !state.template_contract?.amount - ? undefined - : Amounts.parse(state.template_contract?.amount); - - const errors: FormErrors<Entity> = { - 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 - ? i18n.str`should not be empty` - : undefined, - template_contract: !state.template_contract - ? undefined - : undefinedIfEmpty({ - amount: !( - state.type === Steps.FIXED_PRICE || state.type === Steps.BOTH_FIXED - ) - ? undefined - : !state.template_contract?.amount - ? i18n.str`required` - : !parsedPrice - ? i18n.str`not valid` - : Amounts.isZero(parsedPrice) - ? i18n.str`must be greater than 0` - : undefined, - summary: !( - state.type === Steps.FIXED_SUMMARY || - state.type === Steps.BOTH_FIXED - ) - ? undefined - : !state.template_contract?.summary - ? i18n.str`required` - : undefined, - minimum_age: - state.template_contract.minimum_age < 0 - ? 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 - : state.template_contract.pay_duration.d_us < 1000 * 1000 //less than one second - ? i18n.str`to short` - : undefined, - } as Partial<TalerMerchantApi.TemplateContractDetails>), - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submitForm = () => { - if (hasErrors) return Promise.reject(); - if (state.template_contract) { - if (state.type === Steps.NON_FIXED) { - delete state.template_contract.amount; - delete state.template_contract.summary; - } else if (state.type === Steps.FIXED_SUMMARY) { - delete state.template_contract.amount; - } else if (state.type === Steps.FIXED_PRICE) { - delete state.template_contract.summary; - } - } - delete state.type; - return onCreate(state as any); - }; - - const deviceList = !devices.ok ? [] : devices.data.otp_devices; - - 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} - > - <InputWithAddon<Entity> - name="template_id" - help={`${backendURL}/templates/${state.template_id ?? ""}`} - label={i18n.str`Identifier`} - tooltip={i18n.str`Name of the template in URLs.`} - /> - <Input<Entity> - name="template_description" - label={i18n.str`Description`} - help="" - tooltip={i18n.str`Describe what this template stands for`} - /> - <InputTab - name="type" - label={i18n.str`Type`} - help={(() => { - switch (state.type) { - case Steps.NON_FIXED: - return i18n.str`User will be able to input price and summary before payment.`; - case Steps.FIXED_PRICE: - return i18n.str`User will be able to add a summary before payment.`; - case Steps.FIXED_SUMMARY: - return i18n.str`User will be able to set the price before payment.`; - case Steps.BOTH_FIXED: - return i18n.str`User will not be able to change the price or the summary.`; - } - })()} - tooltip={i18n.str`Define what the user be allowed to modify`} - values={[ - Steps.NON_FIXED, - Steps.FIXED_PRICE, - Steps.FIXED_SUMMARY, - Steps.BOTH_FIXED, - ]} - toStr={(v: Steps): string => { - switch (v) { - case Steps.NON_FIXED: - return i18n.str`Simple`; - case Steps.FIXED_PRICE: - return i18n.str`With price`; - case Steps.FIXED_SUMMARY: - return i18n.str`With summary`; - case Steps.BOTH_FIXED: - return i18n.str`With price and summary`; - } - }} - /> - {state.type === Steps.BOTH_FIXED || - state.type === Steps.FIXED_SUMMARY ? ( - <Input - name="template_contract.summary" - inputType="multiline" - label={i18n.str`Fixed summary`} - tooltip={i18n.str`If specified, this template will create order with the same summary`} - /> - ) : undefined} - {state.type === Steps.BOTH_FIXED || - state.type === Steps.FIXED_PRICE ? ( - <InputCurrency - name="template_contract.amount" - label={i18n.str`Fixed price`} - tooltip={i18n.str`If specified, this template will create order with the same price`} - /> - ) : undefined} - <InputNumber - name="template_contract.minimum_age" - label={i18n.str`Minimum age`} - help="" - tooltip={i18n.str`Is this contract restricted to some age?`} - /> - <InputDuration - name="template_contract.pay_duration" - label={i18n.str`Payment timeout`} - help="" - tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} - /> - <Input<Entity> - name="otp_id" - label={i18n.str`OTP device`} - readonly - tooltip={i18n.str`Use to verify transaction in offline mode.`} - /> - <InputSearchOnList - label={i18n.str`Search device`} - onChange={(p) => setState((v) => ({ ...v, otp_id: p?.id }))} - list={deviceList.map((e) => ({ - description: e.device_description, - id: e.otp_device_id, - }))} - /> - </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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/index.tsx deleted file mode 100644 index a29ee53b6..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - 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 { useTemplateAPI } from "../../../../hooks/templates.js"; -import { Notification } from "../../../../utils/types.js"; -import { CreatePage } from "./CreatePage.js"; - -export type Entity = MerchantBackend.Transfers.TransferInformation; -interface Props { - onBack?: () => void; - onConfirm: () => void; -} - -export default function CreateTransfer({ onConfirm, onBack }: Props): VNode { - const { createTemplate } = useTemplateAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - - return ( - <> - <NotificationCard notification={notif} /> - <CreatePage - onBack={onBack} - onCreate={(request: MerchantBackend.Template.TemplateAddDetails) => { - return createTemplate(request) - .then(() => onConfirm()) - .catch((error) => { - setNotif({ - message: i18n.str`could not inform template`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx deleted file mode 100644 index 702e9ba4a..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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/Templates/List", - component: TestedComponent, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx deleted file mode 100644 index bf6062c34..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - 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 { - templates: MerchantBackend.Template.TemplateEntry[]; - onLoadMoreBefore?: () => void; - onLoadMoreAfter?: () => void; - onCreate: () => void; - onDelete: (e: MerchantBackend.Template.TemplateEntry) => void; - onSelect: (e: MerchantBackend.Template.TemplateEntry) => void; - onNewOrder: (e: MerchantBackend.Template.TemplateEntry) => void; - onQR: (e: MerchantBackend.Template.TemplateEntry) => void; -} - -export function ListPage({ - templates, - onCreate, - onDelete, - onSelect, - onNewOrder, - onQR, - onLoadMoreBefore, - onLoadMoreAfter, -}: Props): VNode { - const form = { payto_uri: "" }; - - const { i18n } = useTranslationContext(); - return ( - <CardTable - templates={templates.map((o) => ({ - ...o, - id: String(o.template_id), - }))} - onQR={onQR} - onCreate={onCreate} - onDelete={onDelete} - onSelect={onSelect} - onNewOrder={onNewOrder} - onLoadMoreBefore={onLoadMoreBefore} - hasMoreBefore={!onLoadMoreBefore} - onLoadMoreAfter={onLoadMoreAfter} - hasMoreAfter={!onLoadMoreAfter} - /> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/Table.tsx deleted file mode 100644 index 9fdf4ead9..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/Table.tsx +++ /dev/null @@ -1,235 +0,0 @@ -/* - 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 { StateUpdater, useState } from "preact/hooks"; -import { MerchantBackend } from "../../../../declaration.js"; - -type Entity = MerchantBackend.Template.TemplateEntry; - -interface Props { - templates: Entity[]; - onDelete: (e: Entity) => void; - onSelect: (e: Entity) => void; - onNewOrder: (e: Entity) => void; - onQR: (e: Entity) => void; - onCreate: () => void; - onLoadMoreBefore?: () => void; - hasMoreBefore?: boolean; - hasMoreAfter?: boolean; - onLoadMoreAfter?: () => void; -} - -export function CardTable({ - templates, - onCreate, - onDelete, - onSelect, - onQR, - onNewOrder, - 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>Templates</i18n.Translate> - </p> - <div class="card-header-icon" aria-label="more options"> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`add new templates`} - > - <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"> - {templates.length > 0 ? ( - <Table - instances={templates} - onDelete={onDelete} - onSelect={onSelect} - onNewOrder={onNewOrder} - onQR={onQR} - rowSelection={rowSelection} - rowSelectionHandler={rowSelectionHandler} - onLoadMoreAfter={onLoadMoreAfter} - onLoadMoreBefore={onLoadMoreBefore} - hasMoreAfter={hasMoreAfter} - hasMoreBefore={hasMoreBefore} - /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - ); -} -interface TableProps { - rowSelection: string[]; - instances: Entity[]; - onDelete: (e: Entity) => void; - onNewOrder: (e: Entity) => void; - onQR: (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({ - instances, - onLoadMoreAfter, - onDelete, - onNewOrder, - onQR, - onSelect, - onLoadMoreBefore, - hasMoreAfter, - hasMoreBefore, -}: TableProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="table-container"> - {hasMoreBefore && ( - <button - class="button is-fullwidth" - data-tooltip={i18n.str`load more templates before the first one`} - onClick={onLoadMoreBefore} - > - <i18n.Translate>load newer templates</i18n.Translate> - </button> - )} - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>ID</i18n.Translate> - </th> - <th> - <i18n.Translate>Description</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - return ( - <tr key={i.template_id}> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.template_id} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.template_description} - </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 templates from the database`} - onClick={() => onDelete(i)} - > - Delete - </button> - <button - class="button is-info is-small has-tooltip-left" - data-tooltip={i18n.str`use template to create new order`} - onClick={() => onNewOrder(i)} - > - Use template - </button> - <button - class="button is-info is-small has-tooltip-left" - data-tooltip={i18n.str`create qr code for the template`} - onClick={() => onQR(i)} - > - QR - </button> - </div> - </td> - </tr> - ); - })} - </tbody> - </table> - {hasMoreAfter && ( - <button - class="button is-fullwidth" - data-tooltip={i18n.str`load more templates after the last one`} - onClick={onLoadMoreAfter} - > - <i18n.Translate>load older templates</i18n.Translate> - </button> - )} - </div> - ); -} - -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 templates yet, add more pressing the + sign - </i18n.Translate> - </p> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/index.tsx deleted file mode 100644 index c7927b772..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/index.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } 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 { - useInstanceTemplates, - useTemplateAPI, -} from "../../../../hooks/templates.js"; -import { Notification } from "../../../../utils/types.js"; -import { ListPage } from "./ListPage.js"; -import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; -import { ConfirmModal } from "../../../../components/modal/index.js"; -import { JumpToElementById } from "../../../../components/form/JumpToElementById.js"; - -interface Props { - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onNotFound: () => VNode; - onCreate: () => void; - onSelect: (id: string) => void; - onNewOrder: (id: string) => void; - onQR: (id: string) => void; -} - -export default function ListTemplates({ - onUnauthorized, - onLoadError, - onCreate, - onQR, - onSelect, - onNewOrder, - onNotFound, -}: Props): VNode { - const [position, setPosition] = useState<string | undefined>(undefined); - const { i18n } = useTranslationContext(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { deleteTemplate, testTemplateExist } = useTemplateAPI(); - const result = useInstanceTemplates({ position }, (id) => setPosition(id)); - const [deleting, setDeleting] = - useState<MerchantBackend.Template.TemplateEntry | null>(null); - - 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 ( - <section class="section is-main-section"> - <NotificationCard notification={notif} /> - - <JumpToElementById - testIfExist={testTemplateExist} - onSelect={onSelect} - description={i18n.str`jump to template with the given template ID`} - placeholder={i18n.str`template id`} - /> - - <ListPage - templates={result.data.templates} - onLoadMoreBefore={ - result.isReachingStart ? result.loadMorePrev : undefined - } - onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined} - onCreate={onCreate} - onSelect={(e) => { - onSelect(e.template_id); - }} - onNewOrder={(e) => { - onNewOrder(e.template_id); - }} - onQR={(e) => { - onQR(e.template_id); - }} - onDelete={(e: MerchantBackend.Template.TemplateEntry) => { - setDeleting(e) - } - } - /> - - {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>"{deleting.template_description}"</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> - )} - </section> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx deleted file mode 100644 index eb853c8ff..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - 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 { QrPage as TestedComponent } from "./QrPage.js"; - -export default { - title: "Pages/Templates/QR", - component: TestedComponent, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx deleted file mode 100644 index f2276b0c4..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/* - 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 { stringifyPayTemplateUri } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { QR } from "../../../../components/exception/QR.js"; -import { - FormErrors, - FormProvider, -} from "../../../../components/form/FormProvider.js"; -import { Input } from "../../../../components/form/Input.js"; -import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { useBackendContext } from "../../../../context/backend.js"; -import { useConfigContext } from "../../../../context/config.js"; -import { useInstanceContext } from "../../../../context/instance.js"; -import { MerchantBackend } from "../../../../declaration.js"; - -type Entity = MerchantBackend.Template.UsingTemplateDetails; - -interface Props { - contract: MerchantBackend.Template.TemplateContractDetails; - id: string; - onBack?: () => void; -} - -export function QrPage({ contract, id: templateId, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext() - const { id: instanceId } = useInstanceContext(); - const config = useConfigContext(); - - const [state, setState] = useState<Partial<Entity>>({ - amount: contract.amount, - summary: contract.summary, - }); - - const errors: FormErrors<Entity> = {}; - - const fixedAmount = !!contract.amount; - const fixedSummary = !!contract.summary; - - const templateParams: Record<string, string> = {} - if (!fixedAmount) { - if (state.amount) { - templateParams.amount = state.amount - } else { - templateParams.amount = config.currency - } - } - - if (!fixedSummary) { - templateParams.summary = state.summary ?? "" - } - - const merchantBaseUrl = new URL(backendURL).href; - - const payTemplateUri = stringifyPayTemplateUri({ - merchantBaseUrl, - templateId, - //templateParams - }) - - const issuer = encodeURIComponent( - `${new URL(backendURL).host}/${instanceId}`, - ); - - return ( - <div> - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <p class="is-size-5 mt-5 mb-5"> - <i18n.Translate> - Here you can specify a default value for fields that are not - fixed. Default values can be edited by the customer before the - payment. - </i18n.Translate> - </p> - - <p></p> - <FormProvider - object={state} - valueHandler={setState} - errors={errors} - > - <InputCurrency<Entity> - name="amount" - label={ - fixedAmount - ? i18n.str`Fixed amount` - : i18n.str`Default amount` - } - readonly={fixedAmount} - tooltip={i18n.str`Amount of the order`} - /> - <Input<Entity> - name="summary" - inputType="multiline" - readonly={fixedSummary} - label={ - fixedSummary - ? i18n.str`Fixed summary` - : i18n.str`Default summary` - } - tooltip={i18n.str`Title of the order to be shown to the customer`} - /> - </FormProvider> - - <div class="buttons is-right mt-5"> - {onBack && ( - <button class="button" onClick={onBack}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - )} - <button - class="button is-info" - onClick={() => saveAsPDF(templateId)} - > - <i18n.Translate>Print</i18n.Translate> - </button> - </div> - </div> - <div class="column" /> - </div> - </section> - <section id="printThis"> - <QR text={payTemplateUri} /> - <pre style={{ textAlign: "center" }}> - <a href={payTemplateUri}>{payTemplateUri}</a> - </pre> - </section> - </div> - ); -} - -function saveAsPDF(name: string): void { - const printWindow = window.open("", "", "height=400,width=800"); - if (!printWindow) return; - const divContents = document.getElementById("printThis"); - if (!divContents) return; - printWindow.document.write( - `<html><head><title>Order template for ${name}</title><style>`, - ); - printWindow.document.write("</style></head><body> </body></html>"); - printWindow.document.close(); - printWindow.document.body.appendChild(divContents.cloneNode(true)); - printWindow.addEventListener("load", () => { - printWindow.print(); - printWindow.close(); - }); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx deleted file mode 100644 index 8d07cb31f..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - 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/Templates/Update", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx deleted file mode 100644 index 2b73536fb..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx +++ /dev/null @@ -1,269 +0,0 @@ -/* - 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 { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } 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 { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputDuration } from "../../../../components/form/InputDuration.js"; -import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { InputTab } from "../../../../components/form/InputTab.js"; -import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; -import { useBackendContext } from "../../../../context/backend.js"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { undefinedIfEmpty } from "../../../../utils/table.js"; - -enum Steps { - BOTH_FIXED, - FIXED_PRICE, - FIXED_SUMMARY, - NON_FIXED, -} - -type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; - -interface Props { - onUpdate: (d: Entity) => Promise<void>; - onBack?: () => void; - template: Entity; -} - -export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext(); - - const intialStep = - template.template_contract?.amount === undefined && - template.template_contract?.summary === undefined - ? Steps.NON_FIXED - : template.template_contract?.summary === undefined - ? Steps.FIXED_PRICE - : template.template_contract?.amount === undefined - ? Steps.FIXED_SUMMARY - : Steps.BOTH_FIXED; - - const [state, setState] = useState<Partial<Entity & { type: Steps }>>({ - ...template, - type: intialStep, - }); - - const parsedPrice = !state.template_contract?.amount - ? undefined - : Amounts.parse(state.template_contract?.amount); - - const errors: FormErrors<Entity> = { - template_description: !state.template_description - ? i18n.str`should not be empty` - : undefined, - template_contract: !state.template_contract - ? undefined - : undefinedIfEmpty({ - amount: !( - state.type === Steps.FIXED_PRICE || state.type === Steps.BOTH_FIXED - ) - ? undefined - : !state.template_contract?.amount - ? i18n.str`required` - : !parsedPrice - ? i18n.str`not valid` - : Amounts.isZero(parsedPrice) - ? i18n.str`must be greater than 0` - : undefined, - summary: !( - state.type === Steps.FIXED_SUMMARY || - state.type === Steps.BOTH_FIXED - ) - ? undefined - : !state.template_contract?.summary - ? i18n.str`required` - : undefined, - minimum_age: - state.template_contract.minimum_age < 0 - ? 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 - : state.template_contract.pay_duration.d_us < 1000 * 1000 // less than one second - ? i18n.str`to short` - : undefined, - } as Partial<TalerMerchantApi.TemplateContractDetails>), - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submitForm = () => { - if (hasErrors) return Promise.reject(); - if (state.template_contract) { - if (state.type === Steps.NON_FIXED) { - delete state.template_contract.amount; - delete state.template_contract.summary; - } else if (state.type === Steps.FIXED_SUMMARY) { - delete state.template_contract.amount; - } else if (state.type === Steps.FIXED_PRICE) { - delete state.template_contract.summary; - } - } - delete state.type; - 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"> - {backendURL}/templates/{template.id} - </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} - > - <InputWithAddon<Entity> - name="id" - addonBefore={`templates/`} - readonly - label={i18n.str`Identifier`} - tooltip={i18n.str`Name of the template in URLs.`} - /> - - <Input<Entity> - name="template_description" - label={i18n.str`Description`} - help="" - tooltip={i18n.str`Describe what this template stands for`} - /> - <InputTab - name="type" - label={i18n.str`Type`} - help={(() => { - switch (state.type) { - case Steps.NON_FIXED: - return i18n.str`User will be able to input price and summary before payment.`; - case Steps.FIXED_PRICE: - return i18n.str`User will be able to add a summary before payment.`; - case Steps.FIXED_SUMMARY: - return i18n.str`User will be able to set the price before payment.`; - case Steps.BOTH_FIXED: - return i18n.str`User will not be able to change the price or the summary.`; - } - })()} - tooltip={i18n.str`Define what the user be allowed to modify`} - values={[ - Steps.NON_FIXED, - Steps.FIXED_PRICE, - Steps.FIXED_SUMMARY, - Steps.BOTH_FIXED, - ]} - toStr={(v: Steps): string => { - switch (v) { - case Steps.NON_FIXED: - return i18n.str`Simple`; - case Steps.FIXED_PRICE: - return i18n.str`With price`; - case Steps.FIXED_SUMMARY: - return i18n.str`With summary`; - case Steps.BOTH_FIXED: - return i18n.str`With price and summary`; - } - }} - /> - {state.type === Steps.BOTH_FIXED || - state.type === Steps.FIXED_SUMMARY ? ( - <Input - name="template_contract.summary" - inputType="multiline" - label={i18n.str`Fixed summary`} - tooltip={i18n.str`If specified, this template will create order with the same summary`} - /> - ) : undefined} - {state.type === Steps.BOTH_FIXED || - state.type === Steps.FIXED_PRICE ? ( - <InputCurrency - name="template_contract.amount" - label={i18n.str`Fixed price`} - tooltip={i18n.str`If specified, this template will create order with the same price`} - /> - ) : undefined} - <InputNumber - name="template_contract.minimum_age" - label={i18n.str`Minimum age`} - help="" - tooltip={i18n.str`Is this contract restricted to some age?`} - /> - <InputDuration - name="template_contract.pay_duration" - label={i18n.str`Payment timeout`} - help="" - tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} - /> - </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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/index.tsx deleted file mode 100644 index 3adca45db..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/index.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } 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 { - useTemplateAPI, - useTemplateDetails, -} from "../../../../hooks/templates.js"; -import { Notification } from "../../../../utils/types.js"; -import { UpdatePage } from "./UpdatePage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -export type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; - -interface Props { - onBack?: () => void; - onConfirm: () => void; - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - tid: string; -} -export default function UpdateTemplate({ - tid, - onConfirm, - onBack, - onUnauthorized, - onNotFound, - onLoadError, -}: Props): VNode { - const { updateTemplate } = useTemplateAPI(); - const result = useTemplateDetails(tid); - 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 - template={{ ...result.data, id: tid }} - onBack={onBack} - onUpdate={(data) => { - return updateTemplate(tid, data) - .then(onConfirm) - .catch((error) => { - setNotif({ - message: i18n.str`could not update template`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx deleted file mode 100644 index 13576d94d..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - 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 { UsePage as TestedComponent } from "./UsePage.js"; - -export default { - title: "Pages/Templates/Create", - component: TestedComponent, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx deleted file mode 100644 index 983804d3e..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/* - 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 { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { MerchantBackend } from "../../../../declaration.js"; - -type Entity = MerchantBackend.Template.UsingTemplateDetails; - -interface Props { - id: string; - template: MerchantBackend.Template.TemplateDetails; - onCreateOrder: (d: Entity) => Promise<void>; - onBack?: () => void; -} - -export function UsePage({ id, template, onCreateOrder, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - - const [state, setState] = useState<Partial<Entity>>({ - amount: template.template_contract.amount, - summary: template.template_contract.summary, - }); - - const errors: FormErrors<Entity> = { - amount: - !template.template_contract.amount && !state.amount - ? i18n.str`Amount is required` - : undefined, - summary: - !template.template_contract.summary && !state.summary - ? i18n.str`Order summary is required` - : undefined, - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submitForm = () => { - if (hasErrors) return Promise.reject(); - if (template.template_contract.amount) { - delete state.amount; - } - if (template.template_contract.summary) { - delete state.summary; - } - return onCreateOrder(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"> - <i18n.Translate>New order for template</i18n.Translate>:{" "} - <b>{id}</b> - </span> - </div> - </div> - </div> - </div> - </section> - </section> - <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} - > - <InputCurrency<Entity> - name="amount" - label={i18n.str`Amount`} - readonly={!!template.template_contract.amount} - tooltip={i18n.str`Amount of the order`} - /> - <Input<Entity> - name="summary" - inputType="multiline" - label={i18n.str`Order summary`} - readonly={!!template.template_contract.summary} - tooltip={i18n.str`Title of the order to be shown to the customer`} - /> - </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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/index.tsx deleted file mode 100644 index ed1242ef5..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } 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 { - useTemplateAPI, - useTemplateDetails, -} from "../../../../hooks/templates.js"; -import { Notification } from "../../../../utils/types.js"; -import { UsePage } from "./UsePage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -export type Entity = MerchantBackend.Transfers.TransferInformation; -interface Props { - onBack?: () => void; - onOrderCreated: (id: string) => void; - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - tid: string; -} - -export default function TemplateUsePage({ - tid, - onOrderCreated, - onBack, - onLoadError, - onNotFound, - onUnauthorized, -}: Props): VNode { - const { createOrderFromTemplate } = useTemplateAPI(); - const result = useTemplateDetails(tid); - 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 ( - <> - <NotificationCard notification={notif} /> - <UsePage - template={result.data} - id={tid} - onBack={onBack} - onCreateOrder={( - request: MerchantBackend.Template.UsingTemplateDetails, - ) => { - return createOrderFromTemplate(tid, request) - .then((res) => onOrderCreated(res.data.order_id)) - .catch((error) => { - setNotif({ - message: i18n.str`could not create order from template`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/token/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/token/DetailPage.tsx deleted file mode 100644 index 549e7581f..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/token/DetailPage.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/* - 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"; -import { AccessToken } from "../../../declaration.js"; -import { NotificationCard } from "../../../components/menu/index.js"; - -interface Props { - instanceId: string; - hasToken: boolean | undefined; - onClearToken: (c: AccessToken | undefined) => void; - onNewToken: (c: AccessToken | undefined, s: AccessToken) => void; - onBack?: () => void; -} - -export function DetailPage({ instanceId, hasToken, 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 errors = { - old_token: hasToken && !form.old_token - ? i18n.str`you need your access token to perform the operation` - : 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; - const oldToken = hasToken ? `secret-token:${form.old_token}` as AccessToken : undefined; - const newToken = `secret-token:${form.new_token}` as AccessToken; - onNewToken(oldToken, newToken) - } - - 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"> - {text} - </span> - </div> - </div> - </div> - </div> - </section> - <hr /> - - {!hasToken && - <NotificationCard - notification={{ - message: i18n.str`This instance doesn't have authentication token.`, - description: i18n.str`You can leave it empty if there is another layer of security.`, - type: "WARN", - }} - /> - } - - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <FormProvider errors={errors} object={form} valueHandler={setValue}> - <Fragment> - {hasToken && ( - <Fragment> - <Input<State> - name="old_token" - label={i18n.str`Current access token`} - tooltip={i18n.str`access token currently in use`} - inputType="password" - /> - <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 - class="button" - onClick={() => { - if (hasToken) { - const oldToken = `secret-token:${form.old_token}` as AccessToken; - onClearToken(oldToken) - } else { - onClearToken(undefined) - } - }} - > - <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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/token/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/token/index.tsx deleted file mode 100644 index 22365c9e1..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/token/index.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - 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 { AccessToken, 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; - onCancel: () => void; -} - -export default function Token({ - onLoadError, - onChange, - onUnauthorized, - onNotFound, - onCancel, -}: Props): VNode { - const { i18n } = useTranslationContext(); - - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { clearAccessToken, setNewAccessToken } = useInstanceAPI(); - const { id } = useInstanceContext(); - const result = useInstanceDetails() - - 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); - } - - const hasToken = result.data.auth.method === "token" - - return ( - <Fragment> - <NotificationCard notification={notif} /> - <DetailPage - instanceId={id} - onBack={onCancel} - hasToken={hasToken} - onClearToken={async (currentToken): Promise<void> => { - try { - await clearAccessToken(currentToken); - onChange(); - } catch (error) { - if (error instanceof Error) { - setNotif({ - message: i18n.str`Failed to clear token`, - type: "ERROR", - description: error.message, - }); - } - } - }} - onNewToken={async (currentToken, newToken): Promise<void> => { - try { - await setNewAccessToken(currentToken, newToken); - onChange(); - } catch (error) { - if (error instanceof Error) { - setNotif({ - message: i18n.str`Failed to set new token`, - type: "ERROR", - description: error.message, - }); - } - } - }} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/token/stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/token/stories.tsx deleted file mode 100644 index 5f0f56f2d..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/token/stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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, -}; - diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx deleted file mode 100644 index 64b67335c..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - 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/Transfer/Create", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, { - accounts: ["payto://x-taler-bank/account1", "payto://x-taler-bank/account2"], -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx deleted file mode 100644 index 13f5f3c12..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/* - 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 { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; -import { useConfigContext } from "../../../../context/config.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { - CROCKFORD_BASE32_REGEX, - URL_REGEX, -} from "../../../../utils/constants.js"; - -type Entity = MerchantBackend.Transfers.TransferInformation; - -interface Props { - onCreate: (d: Entity) => Promise<void>; - onBack?: () => void; - accounts: string[]; -} - -export function CreatePage({ accounts, onCreate, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - const { currency } = useConfigContext(); - - const [state, setState] = useState<Partial<Entity>>({ - wtid: "", - // payto_uri: , - // exchange_url: 'http://exchange.taler:8081/', - credit_amount: ``, - }); - - const errors: FormErrors<Entity> = { - wtid: !state.wtid - ? i18n.str`cannot be empty` - : !CROCKFORD_BASE32_REGEX.test(state.wtid) - ? i18n.str`check the id, does not look valid` - : state.wtid.length !== 52 - ? i18n.str`should have 52 characters, current ${state.wtid.length}` - : undefined, - payto_uri: !state.payto_uri ? i18n.str`cannot be empty` : undefined, - credit_amount: !state.credit_amount ? i18n.str`cannot be empty` : undefined, - exchange_url: !state.exchange_url - ? i18n.str`cannot be empty` - : !URL_REGEX.test(state.exchange_url) - ? i18n.str`URL doesn't have the right format` - : undefined, - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submitForm = () => { - if (hasErrors) return Promise.reject(); - 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} - > - <InputSelector - name="payto_uri" - label={i18n.str`Credited bank account`} - values={accounts} - placeholder={i18n.str`Select one account`} - tooltip={i18n.str`Bank account of the merchant where the payment was received`} - /> - <Input<Entity> - name="wtid" - label={i18n.str`Wire transfer ID`} - help="" - tooltip={i18n.str`unique identifier of the wire transfer used by the exchange, must be 52 characters long`} - /> - <Input<Entity> - name="exchange_url" - label={i18n.str`Exchange URL`} - tooltip={i18n.str`Base URL of the exchange that made the transfer, should have been in the wire transfer subject`} - help="http://exchange.taler:8081/" - /> - <InputCurrency<Entity> - name="credit_amount" - label={i18n.str`Amount credited`} - tooltip={i18n.str`Actual amount that was wired to the merchant's bank account`} - /> - </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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/index.tsx deleted file mode 100644 index 25551a031..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - 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 { useInstanceDetails } from "../../../../hooks/instance.js"; -import { useTransferAPI } from "../../../../hooks/transfer.js"; -import { Notification } from "../../../../utils/types.js"; -import { CreatePage } from "./CreatePage.js"; -import { useBankAccountDetails, useInstanceBankAccounts } from "../../../../hooks/bank.js"; - -export type Entity = MerchantBackend.Transfers.TransferInformation; -interface Props { - onBack?: () => void; - onConfirm: () => void; -} - -export default function CreateTransfer({ onConfirm, onBack }: Props): VNode { - const { informTransfer } = useTransferAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); - const instance = useInstanceBankAccounts(); - const accounts = !instance.ok - ? [] - : instance.data.accounts.map((a) => a.payto_uri); - - return ( - <> - <NotificationCard notification={notif} /> - <CreatePage - onBack={onBack} - accounts={accounts} - onCreate={(request: MerchantBackend.Transfers.TransferInformation) => { - return informTransfer(request) - .then(() => onConfirm()) - .catch((error) => { - setNotif({ - message: i18n.str`could not inform transfer`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx deleted file mode 100644 index 92b3f9853..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - 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 { ListPage as TestedComponent } from "./ListPage.js"; - -export default { - title: "Pages/Transfer/List", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onDelete: { action: "onDelete" }, - onLoadMoreBefore: { action: "onLoadMoreBefore" }, - onLoadMoreAfter: { action: "onLoadMoreAfter" }, - onShowAll: { action: "onShowAll" }, - onShowVerified: { action: "onShowVerified" }, - onShowUnverified: { action: "onShowUnverified" }, - onChangePayTo: { action: "onChangePayTo" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, { - transfers: [ - { - exchange_url: "http://exchange.url/", - credit_amount: "TESTKUDOS:10", - payto_uri: "payto//x-taler-bank/bank:8080/account", - transfer_serial_id: 123123123, - wtid: "!@KJELQKWEJ!L@K#!J@", - confirmed: true, - execution_time: { - t_s: new Date().getTime() / 1000, - }, - verified: false, - }, - { - exchange_url: "http://exchange.url/", - credit_amount: "TESTKUDOS:10", - payto_uri: "payto//x-taler-bank/bank:8080/account", - transfer_serial_id: 123123123, - wtid: "!@KJELQKWEJ!L@K#!J@", - confirmed: true, - execution_time: { - t_s: new Date().getTime() / 1000, - }, - verified: false, - }, - { - exchange_url: "http://exchange.url/", - credit_amount: "TESTKUDOS:10", - payto_uri: "payto//x-taler-bank/bank:8080/account", - transfer_serial_id: 123123123, - wtid: "!@KJELQKWEJ!L@K#!J@", - confirmed: true, - execution_time: { - t_s: new Date().getTime() / 1000, - }, - verified: false, - }, - ], - accounts: ["payto://x-taler-bank/bank/some_account"], -}); -export const Empty = createExample(TestedComponent, { - transfers: [], - accounts: [], -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx deleted file mode 100644 index 02b12c4c2..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* - 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 { FormProvider } from "../../../../components/form/FormProvider.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { CardTable } from "./Table.js"; - -export interface Props { - transfers: MerchantBackend.Transfers.TransferDetails[]; - onLoadMoreBefore?: () => void; - onLoadMoreAfter?: () => void; - onShowAll: () => void; - onShowVerified: () => void; - onShowUnverified: () => void; - isVerifiedTransfers?: boolean; - isNonVerifiedTransfers?: boolean; - isAllTransfers?: boolean; - accounts: string[]; - onChangePayTo: (p?: string) => void; - payTo?: string; - onCreate: () => void; - onDelete: () => void; -} - -export function ListPage({ - payTo, - onChangePayTo, - transfers, - onCreate, - onDelete, - accounts, - onLoadMoreBefore, - onLoadMoreAfter, - isAllTransfers, - isNonVerifiedTransfers, - isVerifiedTransfers, - onShowAll, - onShowUnverified, - onShowVerified, -}: Props): VNode { - const form = { payto_uri: payTo }; - - const { i18n } = useTranslationContext(); - return ( - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-10"> - <FormProvider - object={form} - valueHandler={(updater) => onChangePayTo(updater(form).payto_uri)} - > - <InputSelector - name="payto_uri" - label={i18n.str`Account URI`} - values={accounts} - placeholder={i18n.str`Select one account`} - tooltip={i18n.str`filter by account address`} - /> - </FormProvider> - </div> - <div class="column" /> - </div> - <div class="tabs"> - <ul> - <li class={isAllTransfers ? "is-active" : ""}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`remove all filters`} - > - <a onClick={onShowAll}> - <i18n.Translate>All</i18n.Translate> - </a> - </div> - </li> - <li class={isVerifiedTransfers ? "is-active" : ""}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`only show wire transfers confirmed by the merchant`} - > - <a onClick={onShowVerified}> - <i18n.Translate>Verified</i18n.Translate> - </a> - </div> - </li> - <li class={isNonVerifiedTransfers ? "is-active" : ""}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`only show wire transfers claimed by the exchange`} - > - <a onClick={onShowUnverified}> - <i18n.Translate>Unverified</i18n.Translate> - </a> - </div> - </li> - </ul> - </div> - <CardTable - transfers={transfers.map((o) => ({ - ...o, - id: String(o.transfer_serial_id), - }))} - accounts={accounts} - onCreate={onCreate} - onDelete={onDelete} - onLoadMoreBefore={onLoadMoreBefore} - hasMoreBefore={!onLoadMoreBefore} - onLoadMoreAfter={onLoadMoreAfter} - hasMoreAfter={!onLoadMoreAfter} - /> - </section> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/Table.tsx deleted file mode 100644 index b6b1cf328..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/Table.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/* - 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 { format } from "date-fns"; -import { h, VNode } from "preact"; -import { StateUpdater, useState } from "preact/hooks"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; - -type Entity = MerchantBackend.Transfers.TransferDetails & WithId; - -interface Props { - transfers: Entity[]; - onDelete: (id: Entity) => void; - onCreate: () => void; - accounts: string[]; - onLoadMoreBefore?: () => void; - hasMoreBefore?: boolean; - hasMoreAfter?: boolean; - onLoadMoreAfter?: () => void; -} - -export function CardTable({ - transfers, - onCreate, - onDelete, - 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-arrow-left-right" /> - </span> - <i18n.Translate>Transfers</i18n.Translate> - </p> - <div class="card-header-icon" aria-label="more options"> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`add new transfer`} - > - <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"> - {transfers.length > 0 ? ( - <Table - instances={transfers} - onDelete={onDelete} - rowSelection={rowSelection} - rowSelectionHandler={rowSelectionHandler} - onLoadMoreAfter={onLoadMoreAfter} - onLoadMoreBefore={onLoadMoreBefore} - hasMoreAfter={hasMoreAfter} - hasMoreBefore={hasMoreBefore} - /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - ); -} -interface TableProps { - rowSelection: string[]; - instances: Entity[]; - onDelete: (id: 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({ - instances, - onLoadMoreAfter, - onDelete, - onLoadMoreBefore, - hasMoreAfter, - hasMoreBefore, -}: TableProps): VNode { - const { i18n } = useTranslationContext(); - const [settings] = useSettings(); - return ( - <div class="table-container"> - {hasMoreBefore && ( - <button - class="button is-fullwidth" - data-tooltip={i18n.str`load more transfers before the first one`} - onClick={onLoadMoreBefore} - > - <i18n.Translate>load newer transfers</i18n.Translate> - </button> - )} - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>ID</i18n.Translate> - </th> - <th> - <i18n.Translate>Credit</i18n.Translate> - </th> - <th> - <i18n.Translate>Address</i18n.Translate> - </th> - <th> - <i18n.Translate>Exchange URL</i18n.Translate> - </th> - <th> - <i18n.Translate>Confirmed</i18n.Translate> - </th> - <th> - <i18n.Translate>Verified</i18n.Translate> - </th> - <th> - <i18n.Translate>Executed at</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - return ( - <tr key={i.id}> - <td>{i.id}</td> - <td>{i.credit_amount}</td> - <td>{i.payto_uri}</td> - <td>{i.exchange_url}</td> - <td>{i.confirmed ? i18n.str`yes` : i18n.str`no`}</td> - <td>{i.verified ? i18n.str`yes` : i18n.str`no`}</td> - <td> - {i.execution_time - ? i.execution_time.t_s == "never" - ? i18n.str`never` - : format( - i.execution_time.t_s * 1000, - datetimeFormatForSettings(settings), - ) - : i18n.str`unknown`} - </td> - <td> - {i.verified === undefined ? ( - <button - class="button is-danger is-small has-tooltip-left" - data-tooltip={i18n.str`delete selected transfer from the database`} - onClick={() => onDelete(i)} - > - Delete - </button> - ) : undefined} - </td> - </tr> - ); - })} - </tbody> - </table> - {hasMoreAfter && ( - <button - class="button is-fullwidth" - data-tooltip={i18n.str`load more transfer after the last one`} - onClick={onLoadMoreAfter} - > - <i18n.Translate>load older transfers</i18n.Translate> - </button> - )} - </div> - ); -} - -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 transfer yet, add more pressing the + sign - </i18n.Translate> - </p> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/index.tsx deleted file mode 100644 index 0fdbb9bc3..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - 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 { ErrorType, HttpError } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { Loading } from "../../../../components/exception/loading.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { useInstanceDetails } from "../../../../hooks/instance.js"; -import { useInstanceTransfers } from "../../../../hooks/transfer.js"; -import { ListPage } from "./ListPage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { useInstanceBankAccounts } from "../../../../hooks/bank.js"; - -interface Props { - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onNotFound: () => VNode; - onCreate: () => void; -} -interface Form { - verified?: "yes" | "no"; - payto_uri?: string; -} - -export default function ListTransfer({ - onUnauthorized, - onLoadError, - onCreate, - onNotFound, -}: Props): VNode { - const setFilter = (s?: "yes" | "no") => setForm({ ...form, verified: s }); - - const [position, setPosition] = useState<string | undefined>(undefined); - - const instance = useInstanceBankAccounts(); - const accounts = !instance.ok - ? [] - : instance.data.accounts.map((a) => a.payto_uri); - const [form, setForm] = useState<Form>({ payto_uri: "" }); - - const shoulUseDefaultAccount = accounts.length === 1 - useEffect(() => { - if (shoulUseDefaultAccount) { - setForm({...form, payto_uri: accounts[0]}) - } - }, [shoulUseDefaultAccount]) - - const isVerifiedTransfers = form.verified === "yes"; - const isNonVerifiedTransfers = form.verified === "no"; - const isAllTransfers = form.verified === undefined; - - const result = useInstanceTransfers( - { - position, - payto_uri: form.payto_uri === "" ? undefined : form.payto_uri, - verified: form.verified, - }, - (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 ( - <ListPage - accounts={accounts} - transfers={result.data.transfers} - onLoadMoreBefore={ - result.isReachingStart ? result.loadMorePrev : undefined - } - onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined} - onCreate={onCreate} - onDelete={() => { - null; - }} - // position={position} setPosition={setPosition} - onShowAll={() => setFilter(undefined)} - onShowUnverified={() => setFilter("no")} - onShowVerified={() => setFilter("yes")} - isAllTransfers={isAllTransfers} - isVerifiedTransfers={isVerifiedTransfers} - isNonVerifiedTransfers={isNonVerifiedTransfers} - payTo={form.payto_uri} - onChangePayTo={(p) => setForm((v) => ({ ...v, payto_uri: p }))} - /> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/update/Update.stories.tsx deleted file mode 100644 index 817a7025c..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/update/Update.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - 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/Instance/Update", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, { - selected: { - name: "name", - auth: { method: "external" }, - address: {}, - user_type: "business", - use_stefan: true, - jurisdiction: {}, - default_pay_delay: { - d_us: 1000 * 1000, //one second - }, - default_wire_transfer_delay: { - d_us: 1000 * 1000, //one second - }, - merchant_pub: "ASDWQEKASJDKSADJ", - }, -}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/update/UpdatePage.tsx deleted file mode 100644 index a27a0cb06..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/update/UpdatePage.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* - 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 { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; -import { useInstanceContext } from "../../../context/instance.js"; -import { MerchantBackend } from "../../../declaration.js"; -import { undefinedIfEmpty } from "../../../utils/table.js"; -import { Duration } from "@gnu-taler/taler-util"; - -export type Entity = Omit<Omit<MerchantBackend.Instances.InstanceReconfigurationMessage, "default_pay_delay">, "default_wire_transfer_delay"> & { - default_pay_delay: Duration, - default_wire_transfer_delay: Duration, -}; - -//MerchantBackend.Instances.InstanceAuthConfigurationMessage -interface Props { - onUpdate: (d: MerchantBackend.Instances.InstanceReconfigurationMessage) => void; - selected: MerchantBackend.Instances.QueryInstancesResponse; - isLoading: boolean; - onBack: () => void; -} - -function convert( - from: MerchantBackend.Instances.QueryInstancesResponse, -): Entity { - const { default_pay_delay, default_wire_transfer_delay, ...rest } = from; - - const defaults = { - use_stefan: false, - default_pay_delay: Duration.fromTalerProtocolDuration(default_pay_delay), - default_wire_transfer_delay: Duration.fromTalerProtocolDuration(default_wire_transfer_delay), - }; - return { ...defaults, ...rest }; -} - -export function UpdatePage({ - onUpdate, - selected, - onBack, -}: Props): VNode { - const { id } = useInstanceContext(); - - const [value, valueHandler] = useState<Partial<Entity>>(convert(selected)); - - const { i18n } = useTranslationContext(); - - const errors: FormErrors<Entity> = { - name: !value.name ? i18n.str`required` : undefined, - user_type: !value.user_type - ? i18n.str`required` - : value.user_type !== "business" && value.user_type !== "individual" - ? i18n.str`should be business or individual` - : undefined, - default_pay_delay: !value.default_pay_delay - ? i18n.str`required` - : !!value.default_wire_transfer_delay && - value.default_wire_transfer_delay.d_ms !== "forever" && - value.default_pay_delay.d_ms !== "forever" && - value.default_pay_delay.d_ms > value.default_wire_transfer_delay.d_ms ? - i18n.str`pay delay can't be greater than wire transfer delay` : undefined, - default_wire_transfer_delay: !value.default_wire_transfer_delay - ? i18n.str`required` - : undefined, - address: undefinedIfEmpty({ - address_lines: - value.address?.address_lines && value.address?.address_lines.length > 7 - ? i18n.str`max 7 lines` - : undefined, - }), - jurisdiction: undefinedIfEmpty({ - address_lines: - value.address?.address_lines && value.address?.address_lines.length > 7 - ? i18n.str`max 7 lines` - : undefined, - }), - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submit = async (): Promise<void> => { - const { default_pay_delay, default_wire_transfer_delay, ...rest } = value as Required<Entity>; - const result: MerchantBackend.Instances.InstanceReconfigurationMessage = { - default_pay_delay: Duration.toTalerProtocolDuration(default_pay_delay), - default_wire_transfer_delay: Duration.toTalerProtocolDuration(default_wire_transfer_delay), - ...rest, - } - await onUpdate(result); - }; - // const [active, setActive] = useState(false); - - 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"> - <i18n.Translate>Instance id</i18n.Translate>: <b>{id}</b> - </span> - </div> - </div> - </div> - </div> - </section> - - <hr /> - - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <FormProvider<Entity> - errors={errors} - object={value} - valueHandler={valueHandler} - > - <DefaultInstanceFormFields showId={false} /> - </FormProvider> - - <div class="buttons is-right mt-4"> - <button - class="button" - onClick={onBack} - data-tooltip="cancel operation" - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - - <AsyncButton - onClick={submit} - data-tooltip={ - hasErrors - ? i18n.str`Need to complete marked fields` - : "confirm operation" - } - disabled={hasErrors} - > - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </div> - </div> - <div class="column" /> - </div> - </section> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/update/index.tsx deleted file mode 100644 index e44cf5c0f..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/update/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - 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, - HttpResponse, - 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 { useInstanceContext } from "../../../context/instance.js"; -import { AccessToken, MerchantBackend } from "../../../declaration.js"; -import { - useInstanceAPI, - useInstanceDetails, - useManagedInstanceDetails, - useManagementAPI, -} from "../../../hooks/instance.js"; -import { Notification } from "../../../utils/types.js"; -import { UpdatePage } from "./UpdatePage.js"; - -export interface Props { - onBack: () => void; - onConfirm: () => void; - - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onUpdateError: (e: HttpError<MerchantBackend.ErrorDetail>) => void; -} - -export default function Update(props: Props): VNode { - const { updateInstance } = useInstanceAPI(); - const result = useInstanceDetails(); - return CommonUpdate(props, result, updateInstance, ); -} - -export function AdminUpdate(props: Props & { instanceId: string }): VNode { - const { updateInstance } = useManagementAPI( - props.instanceId, - ); - const result = useManagedInstanceDetails(props.instanceId); - return CommonUpdate(props, result, updateInstance, ); -} - -function CommonUpdate( - { - onBack, - onConfirm, - onLoadError, - onNotFound, - onUpdateError, - onUnauthorized, - }: Props, - result: HttpResponse< - MerchantBackend.Instances.QueryInstancesResponse, - MerchantBackend.ErrorDetail - >, - updateInstance: any, -): VNode { - 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 - onBack={onBack} - isLoading={false} - selected={result.data} - onUpdate={( - d: MerchantBackend.Instances.InstanceReconfigurationMessage, - ): Promise<void> => { - return updateInstance(d) - .then(onConfirm) - .catch((error: Error) => - setNotif({ - message: i18n.str`Failed to create instance`, - type: "ERROR", - description: error.message, - }), - ); - }} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx deleted file mode 100644 index 4857ede97..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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/Webhooks/Create", - component: TestedComponent, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx deleted file mode 100644 index bfa2a883e..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/* - 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 { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputDuration } from "../../../../components/form/InputDuration.js"; -import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { useBackendContext } from "../../../../context/backend.js"; -import { MerchantBackend } from "../../../../declaration.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; - -type Entity = MerchantBackend.Webhooks.WebhookAddDetails; - -interface Props { - onCreate: (d: Entity) => Promise<void>; - onBack?: () => void; -} - -const validMethod = ["GET", "POST", "PUT", "PATCH", "HEAD"]; - -export function CreatePage({ onCreate, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - - const [state, setState] = useState<Partial<Entity>>({}); - - const errors: FormErrors<Entity> = { - webhook_id: !state.webhook_id ? i18n.str`required` : undefined, - event_type: !state.event_type ? i18n.str`required` - : state.event_type !== "pay" && state.event_type !== "refund" ? i18n.str`it should be "pay" or "refund"` - : undefined, - http_method: !state.http_method - ? i18n.str`required` - : !validMethod.includes(state.http_method) - ? i18n.str`should be one of '${validMethod.join(", ")}'` - : undefined, - url: !state.url ? i18n.str`required` : undefined, - }; - - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const submitForm = () => { - if (hasErrors) return Promise.reject(); - 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} - > - <Input<Entity> - name="webhook_id" - label={i18n.str`ID`} - tooltip={i18n.str`Webhook ID to use`} - /> - <InputSelector - name="event_type" - label={i18n.str`Event`} - values={[ - i18n.str`Choose one...`, - i18n.str`pay`, - i18n.str`refund`, - ]} - tooltip={i18n.str`The event of the webhook: why the webhook is used`} - /> - <InputSelector - name="http_method" - label={i18n.str`Method`} - values={[ - i18n.str`Choose one...`, - i18n.str`GET`, - i18n.str`POST`, - i18n.str`PUT`, - i18n.str`PATCH`, - i18n.str`HEAD`, - ]} - tooltip={i18n.str`Method used by the webhook`} - /> - - <Input<Entity> - name="url" - label={i18n.str`URL`} - tooltip={i18n.str`URL of the webhook where the customer will be redirected`} - /> - - <p> - The text below support <a target="_blank" rel="noreferrer" href="https://mustache.github.io/mustache.5.html">mustache</a> template engine. Any string - between <pre style={{ display: "inline", padding: 0 }}>{{</pre> and <pre style={{ display: "inline", padding: 0 }}>}}</pre> will - be replaced with replaced with the value of the corresponding variable. - </p> - <p> - For example <pre style={{ display: "inline", padding: 0 }}>{{contract_terms.amount}}</pre> will be replaced - with the the order's price - </p> - <p> - The short list of variables are: - </p> - <div class="menu"> - - <ul class="menu-list" style={{ listStyleType: "disc", marginLeft: 20 }}> - <li><b>contract_terms.summary:</b> order's description </li> - <li><b>contract_terms.amount:</b> order's price </li> - <li><b>order_id:</b> order's unique identification </li> - {state.event_type === "refund" && <Fragment> - <li><b>refund_amout:</b> the amount that was being refunded</li> - <li><b>reason:</b> the reason entered by the merchant staff for granting the refund</li> - <li><b>timestamp:</b> time of the refund in nanoseconds since 1970</li> - </Fragment>} - </ul> - </div> - {/* <Input<Entity> - name="header_template" - label={i18n.str`Http header`} - inputType="multiline" - tooltip={i18n.str`Header template of the webhook`} - /> */} - <Input<Entity> - name="body_template" - inputType="multiline" - label={i18n.str`Http body`} - tooltip={i18n.str`Body template by the webhook`} - /> - </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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx deleted file mode 100644 index 702e9ba4a..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - 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/Templates/List", - component: TestedComponent, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx deleted file mode 100644 index 87e221e3c..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - 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 { - webhooks: MerchantBackend.Webhooks.WebhookEntry[]; - onLoadMoreBefore?: () => void; - onLoadMoreAfter?: () => void; - onCreate: () => void; - onDelete: (e: MerchantBackend.Webhooks.WebhookEntry) => void; - onSelect: (e: MerchantBackend.Webhooks.WebhookEntry) => void; -} - -export function ListPage({ - webhooks, - onCreate, - onDelete, - onSelect, - onLoadMoreBefore, - onLoadMoreAfter, -}: Props): VNode { - const form = { payto_uri: "" }; - - const { i18n } = useTranslationContext(); - return ( - <section class="section is-main-section"> - <CardTable - webhooks={webhooks.map((o) => ({ - ...o, - id: String(o.webhook_id), - }))} - onCreate={onCreate} - onDelete={onDelete} - onSelect={onSelect} - onLoadMoreBefore={onLoadMoreBefore} - hasMoreBefore={!onLoadMoreBefore} - onLoadMoreAfter={onLoadMoreAfter} - hasMoreAfter={!onLoadMoreAfter} - /> - </section> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx deleted file mode 100644 index 42a179d2c..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx +++ /dev/null @@ -1,218 +0,0 @@ -/* - 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 { StateUpdater, useState } from "preact/hooks"; -import { MerchantBackend } from "../../../../declaration.js"; - -type Entity = MerchantBackend.Webhooks.WebhookEntry; - -interface Props { - webhooks: Entity[]; - onDelete: (e: Entity) => void; - onSelect: (e: Entity) => void; - onCreate: () => void; - onLoadMoreBefore?: () => void; - hasMoreBefore?: boolean; - hasMoreAfter?: boolean; - onLoadMoreAfter?: () => void; -} - -export function CardTable({ - webhooks, - 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>Webhooks</i18n.Translate> - </p> - <div class="card-header-icon" aria-label="more options"> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`add new webhooks`} - > - <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"> - {webhooks.length > 0 ? ( - <Table - instances={webhooks} - 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[]; - instances: 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({ - instances, - onLoadMoreAfter, - onDelete, - onSelect, - onLoadMoreBefore, - hasMoreAfter, - hasMoreBefore, -}: TableProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="table-container"> - {hasMoreBefore && ( - <button - class="button is-fullwidth" - data-tooltip={i18n.str`load more webhooks before the first one`} - onClick={onLoadMoreBefore} - > - <i18n.Translate>load newer webhooks</i18n.Translate> - </button> - )} - <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th> - <i18n.Translate>ID</i18n.Translate> - </th> - <th> - <i18n.Translate>Event type</i18n.Translate> - </th> - <th /> - </tr> - </thead> - <tbody> - {instances.map((i) => { - return ( - <tr key={i.webhook_id}> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.webhook_id} - </td> - <td - onClick={(): void => onSelect(i)} - style={{ cursor: "pointer" }} - > - {i.event_type} - </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 webhook from the database`} - onClick={() => onDelete(i)} - > - Delete - </button> - {/* <button - class="button is-info is-small has-tooltip-left" - data-tooltip={i18n.str`test webhook`} - onClick={() => onNewOrder(i)} - > - Test - </button> */} - </div> - </td> - </tr> - ); - })} - </tbody> - </table> - {hasMoreAfter && ( - <button - class="button is-fullwidth" - data-tooltip={i18n.str`load more webhooks after the last one`} - onClick={onLoadMoreAfter} - > - <i18n.Translate>load older webhooks</i18n.Translate> - </button> - )} - </div> - ); -} - -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 webhooks yet, add more pressing the + sign - </i18n.Translate> - </p> - </div> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/index.tsx deleted file mode 100644 index a6f6f1511..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } 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 { - useInstanceWebhooks, - useWebhookAPI, -} from "../../../../hooks/webhooks.js"; -import { Notification } from "../../../../utils/types.js"; -import { ListPage } from "./ListPage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -interface Props { - onUnauthorized: () => VNode; - onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - onNotFound: () => VNode; - onCreate: () => void; - onSelect: (id: string) => void; -} - -export default function ListWebhooks({ - 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 { deleteWebhook } = useWebhookAPI(); - const result = useInstanceWebhooks({ 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 - webhooks={result.data.webhooks} - onLoadMoreBefore={ - result.isReachingStart ? result.loadMorePrev : undefined - } - onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined} - onCreate={onCreate} - onSelect={(e) => { - onSelect(e.webhook_id); - }} - onDelete={(e: MerchantBackend.Webhooks.WebhookEntry) => - deleteWebhook(e.webhook_id) - .then(() => - setNotif({ - message: i18n.str`webhook delete successfully`, - type: "SUCCESS", - }), - ) - .catch((error) => - setNotif({ - message: i18n.str`could not delete the webhook`, - type: "ERROR", - description: error.message, - }), - ) - } - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx deleted file mode 100644 index 8d07cb31f..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - 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/Templates/Update", - component: TestedComponent, - argTypes: { - onUpdate: { action: "onUpdate" }, - onBack: { action: "onBack" }, - }, -}; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx deleted file mode 100644 index 76a23b6e5..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/* - 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 { useBackendContext } from "../../../../context/backend.js"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; - -type Entity = MerchantBackend.Webhooks.WebhookPatchDetails & WithId; - -interface Props { - onUpdate: (d: Entity) => Promise<void>; - onBack?: () => void; - webhook: Entity; -} -const validMethod = ["GET", "POST", "PUT", "PATCH", "HEAD"]; - -export function UpdatePage({ webhook, onUpdate, onBack }: Props): VNode { - const { i18n } = useTranslationContext(); - - const [state, setState] = useState<Partial<Entity>>(webhook); - - const errors: FormErrors<Entity> = { - event_type: !state.event_type ? i18n.str`required` : undefined, - http_method: !state.http_method - ? i18n.str`required` - : !validMethod.includes(state.http_method) - ? i18n.str`should be one of '${validMethod.join(", ")}'` - : undefined, - url: !state.url ? i18n.str`required` : undefined, - }; - - 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"> - Webhook: <b>{webhook.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="event_type" - label={i18n.str`Event`} - tooltip={i18n.str`The event of the webhook: why the webhook is used`} - /> - <Input<Entity> - name="http_method" - label={i18n.str`Method`} - tooltip={i18n.str`Method used by the webhook`} - /> - <Input<Entity> - name="url" - label={i18n.str`URL`} - tooltip={i18n.str`URL of the webhook where the customer will be redirected`} - /> - <Input<Entity> - name="header_template" - label={i18n.str`Header`} - inputType="multiline" - tooltip={i18n.str`Header template of the webhook`} - /> - <Input<Entity> - name="body_template" - inputType="multiline" - label={i18n.str`Body`} - tooltip={i18n.str`Body template by the webhook`} - /> - </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> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/index.tsx deleted file mode 100644 index 3f723ed87..000000000 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/index.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - 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 { - ErrorType, - HttpError, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } 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 { - useWebhookAPI, - useWebhookDetails, -} from "../../../../hooks/webhooks.js"; -import { Notification } from "../../../../utils/types.js"; -import { UpdatePage } from "./UpdatePage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; - -export type Entity = MerchantBackend.Webhooks.WebhookPatchDetails & WithId; - -interface Props { - onBack?: () => void; - onConfirm: () => void; - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - tid: string; -} -export default function UpdateWebhook({ - tid, - onConfirm, - onBack, - onUnauthorized, - onNotFound, - onLoadError, -}: Props): VNode { - const { updateWebhook } = useWebhookAPI(); - const result = useWebhookDetails(tid); - 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 - webhook={{ ...result.data, id: tid }} - onBack={onBack} - onUpdate={(data) => { - return updateWebhook(tid, data) - .then(onConfirm) - .catch((error) => { - setNotif({ - message: i18n.str`could not update template`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </Fragment> - ); -} diff --git a/packages/auditor-backoffice-ui/src/paths/login/index.tsx b/packages/auditor-backoffice-ui/src/paths/login/index.tsx index 1c98b7c9b..c99dc6050 100644 --- a/packages/auditor-backoffice-ui/src/paths/login/index.tsx +++ b/packages/auditor-backoffice-ui/src/paths/login/index.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,187 +16,198 @@ /** * + * @author Nic Eigel * @author Sebastian Javier Marchano (sebasjm) */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, h, VNode } from "preact"; -import { useCallback, useEffect, useState } from "preact/hooks"; -import { useBackendContext } from "../../context/backend.js"; -import { useInstanceContext } from "../../context/instance.js"; -import { AccessToken, LoginToken } from "../../declaration.js"; -import { useCredentialsChecker } from "../../hooks/backend.js"; - -interface Props { - onConfirm: (token: LoginToken | undefined) => void; -} - -function normalizeToken(r: string): AccessToken { - return `secret-token:${r}` as AccessToken; -} - -export function LoginPage({ onConfirm }: Props): VNode { - const { url: backendURL } = useBackendContext(); - const { admin, id } = useInstanceContext(); - const { requestNewLoginToken } = useCredentialsChecker(); +import { ComponentChildren, Fragment, h, VNode } from "preact"; +import { useCallback, useState } from "preact/hooks"; +import { useBackendContext, useBackendTokenContext } from "../../context/backend.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { Notification } from "../../utils/types.js"; +import { useBackendToken } from "../../hooks/backend.js"; +import { Route } from "preact-router"; +import { Paths, Redirect } from "../../InstanceRoutes.js"; + +export function LoginPage(): VNode { const [token, setToken] = useState(""); - + const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); - - const doLogin = useCallback(async function doLoginImpl() { - const secretToken = normalizeToken(token); - const baseUrl = id === undefined ? backendURL : `${backendURL}/instances/${id}` - const result = await requestNewLoginToken(baseUrl, secretToken); - if (result.valid) { - const { token, expiration } = result - onConfirm({ token, expiration }); + + const result = useBackendToken(); + if (!result.ok) { + } + if (result.ok) { + //TODO fixme + const { token } = useBackendTokenContext(); + /* return ( + <Route path="/" component={Redirect} to={Paths.key_figures}/> + );*/ } else { - onConfirm(undefined); + setNotif({ + message: "Your password is incorrect", + type: "ERROR", + }); } - }, [id, token]) - - if (admin && id !== "default") { - //admin trying to access another instance - return (<div class="columns is-centered" style={{ margin: "auto" }}> - <div class="column is-two-thirds "> - <div class="modal-card" style={{ width: "100%", margin: 0 }}> - <header - class="modal-card-head" - style={{ border: "1px solid", borderBottom: 0 }} - > - <p class="modal-card-title">{i18n.str`Login required`}</p> - </header> - <section - class="modal-card-body" - style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} - > - <p> - <i18n.Translate>Need the access token for the instance.</i18n.Translate> - </p> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Access Token</i18n.Translate> - </label> - </div> - <div class="field-body"> - <div class="field"> - <p class="control is-expanded"> - <input - class="input" - type="password" - placeholder={"current access token"} - name="token" - onKeyPress={(e) => - e.keyCode === 13 - ? doLogin() - : null - } - value={token} - onInput={(e): void => setToken(e?.currentTarget.value)} - /> - </p> - </div> + }, [token]); + + return ( + <Route path="/" component={Redirect} to={Paths.key_figures}/> + ); + + return ( + <div class="columns is-centered" style={{ margin: "auto" }}> + <div class="column is-two-thirds "> + <div class="modal-card" style={{ width: "100%", margin: 0 }}> + <header + class="modal-card-head" + style={{ border: "1px solid", borderBottom: 0 }} + > + <p class="modal-card-title">{i18n.str`Token required`}</p> + </header> + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + + <p> + <i18n.Translate>Need the access token for the API.</i18n.Translate> + </p> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Access Token</i18n.Translate> + </label> + </div> + <div class="field-body"> + <div class="field"> + <p class="control is-expanded"> + <input + class="input" + type="password" + placeholder={"current access token"} + name="token" + onKeyPress={(e) => + e.keyCode === 13 + ? doLogin() + : null + } + value={token} + onInput={(e): void => setToken(e?.currentTarget.value)} + /> + </p> </div> </div> - </section> - <footer - class="modal-card-foot " - style={{ - justifyContent: "flex-end", - border: "1px solid", - borderTop: 0, - }} + </div> + </section> + <footer + class="modal-card-foot " + style={{ + justifyContent: "flex-end", + border: "1px solid", + borderTop: 0, + }} + > + <AsyncButton + onClick={() => doLogin()} > - <AsyncButton - onClick={doLogin} - > - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </footer> - </div> + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + </footer> </div> - </div>) - } + </div> + </div>); - return ( - <div class="columns is-centered" style={{ margin: "auto" }}> - <div class="column is-two-thirds "> - <div class="modal-card" style={{ width: "100%", margin: 0 }}> - <header - class="modal-card-head" - style={{ border: "1px solid", borderBottom: 0 }} - > - <p class="modal-card-title">{i18n.str`Login required`}</p> - </header> - <section - class="modal-card-body" - style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} - > - <i18n.Translate>Please enter your access token.</i18n.Translate> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Access Token</i18n.Translate> - </label> - </div> - <div class="field-body"> - <div class="field"> - <p class="control is-expanded"> - <input - class="input" - type="password" - placeholder={"current access token"} - name="token" - onKeyPress={(e) => - e.keyCode === 13 - ? doLogin() - : null - } - value={token} - onInput={(e): void => setToken(e?.currentTarget.value)} - /> - </p> + return (<Fragment> + <NotificationCard notification={notif} /> + <div class="columns is-centered" style={{ margin: "auto" }}> + <div class="column is-two-thirds "> + <div class="modal-card" style={{ width: "100%", margin: 0 }}> + <header + class="modal-card-head" + style={{ border: "1px solid", borderBottom: 0 }} + > + <p class="modal-card-title">{i18n.str`Login required`}</p> + </header> + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + <i18n.Translate>Please enter your access token.</i18n.Translate> + + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Access Token</i18n.Translate> + </label> + </div> + <div class="field-body"> + + <div class="field"> + <p class="control is-expanded"> + <input + class="input" + type="password" + placeholder={"current access token"} + name="token" + onKeyPress={(e) => + e.keyCode === 13 + ? doLogin() + : null + } + value={token} + onInput={(e): void => setToken(e?.currentTarget.value)} + /> + </p> + </div> </div> </div> - </div> - </section> - <footer - class="modal-card-foot " - style={{ - justifyContent: "space-between", - border: "1px solid", - borderTop: 0, - }} - > - <div /> - <AsyncButton - type="is-info" - onClick={doLogin} + </section> + <footer + class="modal-card-foot " + style={{ + justifyContent: "space-between", + border: "1px solid", + borderTop: 0, + }} > - <i18n.Translate>Confirm</i18n.Translate> - </AsyncButton> - </footer> + <div /> + <AsyncButton + type="is-info" + onClick={doLogin} + > + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + + </footer> + </div> </div> </div> - </div> + </Fragment> + ); } -function AsyncButton({ onClick, disabled, type = "", children }: { type?: string, disabled?: boolean, onClick: () => Promise<void>, children: ComponentChildren }): VNode { - const [running, setRunning] = useState(false) +function AsyncButton({ onClick, disabled, type = "", children }: { + type?: string, + disabled?: boolean, + onClick: () => Promise<void>, + children: ComponentChildren +}): VNode { + const [running, setRunning] = useState(false); return <button class={"button " + type} disabled={disabled || running} onClick={() => { - setRunning(true) + setRunning(true); onClick().then(() => { - setRunning(false) + setRunning(false); }).catch(() => { - setRunning(false) - }) + setRunning(false); + }); }}> {children} - </button> + </button>; } diff --git a/packages/auditor-backoffice-ui/src/paths/notfound/index.tsx b/packages/auditor-backoffice-ui/src/paths/notfound/index.tsx index 061a67025..114b95219 100644 --- a/packages/auditor-backoffice-ui/src/paths/notfound/index.tsx +++ b/packages/auditor-backoffice-ui/src/paths/notfound/index.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -23,12 +23,12 @@ import { h, VNode } from "preact"; import { Link } from "preact-router"; export default function NotFoundPage(): VNode { - return ( - <div> - <p>That page doesn't exist.</p> - <Link href="/"> - <h4>Back to Home</h4> - </Link> - </div> - ); + return ( + <div> + <p>That page doesn't exist.</p> + <Link href="/"> + <h4>Back to Home</h4> + </Link> + </div> + ); } diff --git a/packages/auditor-backoffice-ui/src/paths/operations/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/operations/ListPage.tsx new file mode 100644 index 000000000..7f0579b2b --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/operations/ListPage.tsx @@ -0,0 +1,72 @@ +/* + 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 Nic Eigel + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode, Fragment } from "preact"; + +export function ListPage(data: any): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <div class="columns is-fullwidth"> + <div class="column is-fullwidth"> + <div class="card"> + <div class="card-content"> + <table class="table is-striped is-fullwidth"> + <tbody> + <tr> + <th>Finding</th> + <td class="has-text-right"><b>Count</b></td> + <td class="has-text-right"><b>Time difference (s)</b></td> + <td class="has-text-right"><b>Diagnostic</b></td> + </tr> + { + data["data"]["data"][0].map((x: any) => { + const key = Object.keys(x.data)[0]; + let value = Object.values(x.data)[0]; + console.log(value); + if (!!value) + value = 0; + const paramName = key[0].toUpperCase() + key.split("_").join(" ").split("-").join(" ").slice(1, key.length); + return ( + <tr class="is-link"> + <td>{paramName}</td> + <td className="has-text-right"><p + class={value == 0 ? "text-success" : "text-danger"}>{String(value)}</p></td> + <td className="has-text-right">{//TODO + }</td> + <td>{//TODO + }</td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + </div> + </div> + </div> + </Fragment> + ); +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/operations/index.tsx index 8e0f7647f..c05b271fe 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/update/index.tsx +++ b/packages/auditor-backoffice-ui/src/paths/operations/index.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,43 +16,41 @@ /** * + * @author Nic Eigel * @author Sebastian Javier Marchano (sebasjm) */ import { ErrorType, - HttpError, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { h, VNode } 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 { useProductAPI, useProductDetails } from "../../../../hooks/product.js"; -import { Notification } from "../../../../utils/types.js"; -import { UpdatePage } from "./UpdatePage.js"; +import { Loading } from "../../components/exception/loading.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { Notification } from "../../utils/types.js"; +import { ListPage } from "./ListPage.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { getOperationData } from "../../hooks/operational.js"; + -export type Entity = MerchantBackend.Products.ProductAddDetail; interface Props { - onBack?: () => void; - onConfirm: () => void; onUnauthorized: () => VNode; onNotFound: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - pid: string; + onSelect: (id: string) => void; + onCreate: () => void; } -export default function UpdateProduct({ - pid, - onConfirm, - onBack, - onUnauthorized, - onNotFound, - onLoadError, -}: Props): VNode { - const { updateProduct } = useProductAPI(); - const result = useProductDetails(pid); + +export default function OperationsDashboard({ + onUnauthorized, + // onLoadError, + onCreate, + onSelect, + onNotFound, + }: Props): VNode { + + const result = getOperationData(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); @@ -69,27 +67,14 @@ export default function UpdateProduct({ result.status === HttpStatusCode.NotFound ) return onNotFound(); - return onLoadError(result); + else + return onNotFound(); } return ( - <Fragment> + <section class="section is-main-section"> <NotificationCard notification={notif} /> - <UpdatePage - product={{ ...result.data, product_id: pid }} - onBack={onBack} - onUpdate={(data) => { - return updateProduct(pid, data) - .then(onConfirm) - .catch((error) => { - setNotif({ - message: i18n.str`could not create product`, - type: "ERROR", - description: error.message, - }); - }); - }} - /> - </Fragment> + <ListPage data={result} /> + </section> ); -} +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/paths/security/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/security/ListPage.tsx new file mode 100644 index 000000000..74f83bd4a --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/security/ListPage.tsx @@ -0,0 +1,70 @@ +/* + 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 Nic Eigel + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode, Fragment } from "preact"; + +export function ListPage(data: any): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <div class="columns is-fullwidth"> + <div class="column is-fullwidth"> + <div class="card"> + <div class="card-content"> + <table class="table is-striped is-fullwidth"> + <tbody> + <tr> + <th>Finding</th> + <td class="has-text-right"><b>Count</b></td> + <td class="has-text-right"><b>Expiration dates</b></td> + </tr> + { + data["data"]["data"][0].map((x: any) => { + const key = Object.keys(x.data)[0]; + let value = Object.values(x.data)[0]; + console.log(value); + if (!!value) + value = 0; + const paramName = key[0].toUpperCase() + key.split("_").join(" ").split("-").join(" ").slice(1, key.length); + return ( + <tr class="is-link"> + <td>{paramName}</td> + <td class="has-text-right"><p + class={value == 0 ? "text-success" : "text-danger"}>{String(value)}</p></td> + <td class="has-text-right">{ + //TODO + }</td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/index.tsx b/packages/auditor-backoffice-ui/src/paths/security/index.tsx index 7db7478f7..99c98a5e7 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/index.tsx +++ b/packages/auditor-backoffice-ui/src/paths/security/index.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,46 +16,43 @@ /** * + * @author Nic Eigel * @author Sebastian Javier Marchano (sebasjm) */ import { ErrorType, - HttpError, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { h, VNode } 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 { - useTemplateAPI, - useTemplateDetails, -} from "../../../../hooks/templates.js"; -import { Notification } from "../../../../utils/types.js"; -import { QrPage } from "./QrPage.js"; +import { Loading } from "../../components/exception/loading.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { Notification } from "../../utils/types.js"; +import { ListPage } from "./ListPage.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; - -export type Entity = MerchantBackend.Transfers.TransferInformation; +import { getCriticalData } from "../../hooks/critical.js"; interface Props { - onBack?: () => void; onUnauthorized: () => VNode; onNotFound: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - tid: string; + onSelect: (id: string) => void; + onCreate: () => void; } -export default function TemplateQrPage({ - tid, - onBack, - onLoadError, - onNotFound, - onUnauthorized, -}: Props): VNode { - const result = useTemplateDetails(tid); +export default function SecurityDashboard({ + onUnauthorized, + // onLoadError, + onCreate, + onSelect, + onNotFound, + }: Props): VNode { + + const result = getCriticalData(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { i18n } = useTranslationContext(); + if (result.loading) return <Loading />; if (!result.ok) { if ( @@ -68,13 +65,14 @@ export default function TemplateQrPage({ result.status === HttpStatusCode.NotFound ) return onNotFound(); - return onLoadError(result); + else + return onNotFound(); } return ( - <> + <section class="section is-main-section"> <NotificationCard notification={notif} /> - <QrPage contract={result.data.template_contract} id={tid} onBack={onBack} /> - </> + <ListPage data={result} /> + </section> ); -} +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/paths/settings/index.tsx b/packages/auditor-backoffice-ui/src/paths/settings/index.tsx index 093c3d09d..77a56a794 100644 --- a/packages/auditor-backoffice-ui/src/paths/settings/index.tsx +++ b/packages/auditor-backoffice-ui/src/paths/settings/index.tsx @@ -1,8 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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) + * @author Nic Eigel + */ + import { useLang, useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { FormErrors, FormProvider } from "../../components/form/FormProvider.js"; -import { InputSelector } from "../../components/form/InputSelector.js"; -import { InputToggle } from "../../components/form/InputToggle.js"; +import { FormErrors, FormProvider } from "../../components/forms/FormProvider.js"; import { LangSelector } from "../../components/menu/LangSelector.js"; import { Settings, useSettings } from "../../hooks/useSettings.js"; @@ -16,7 +36,7 @@ function getBrowserLang(): string | undefined { export function Settings({ onClose }: { onClose?: () => void }): VNode { const { i18n } = useTranslationContext() const borwserLang = getBrowserLang() - //const { update } = useLang() + const { update } = useLang(undefined, {}) const [value, updateValue] = useSettings() const errors: FormErrors<Settings> = { @@ -60,38 +80,13 @@ export function Settings({ onClose }: { onClose?: () => void }): VNode { data-tooltip={i18n.str`generate random secret key`} class="button is-info mr-2" onClick={(e) => { - //update(borwserLang.substring(0, 2)) + update(borwserLang.substring(0, 2)) }} > <i18n.Translate>Set default</i18n.Translate> </button>} </div> </div> - <InputToggle<Settings> - label={i18n.str`Advance order creation`} - tooltip={i18n.str`Shows more options in the order creation form`} - name="advanceOrderMode" - /> - <InputSelector<Settings> - name="dateFormat" - label={i18n.str`Date format`} - expand={true} - help={ - value.dateFormat === "dmy" ? "31/12/2001" : value.dateFormat === "mdy" ? "12/31/2001" : value.dateFormat === "ymd" ? "2001/12/31" : "" - } - toStr={(e) => { - if (e === "ymd") return "year month day" - if (e === "mdy") return "month day year" - if (e === "dmy") return "day month year" - return "choose one" - }} - values={[ - "ymd", - "mdy", - "dmy", - ]} - tooltip={i18n.str`how the date is going to be displayed`} - /> </FormProvider> </div> </div> diff --git a/packages/auditor-backoffice-ui/src/scss/_aside.scss b/packages/auditor-backoffice-ui/src/scss/_aside.scss index e0922093b..b7b59516b 100644 --- a/packages/auditor-backoffice-ui/src/scss/_aside.scss +++ b/packages/auditor-backoffice-ui/src/scss/_aside.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_card.scss b/packages/auditor-backoffice-ui/src/scss/_card.scss index 62db7f457..a4118400f 100644 --- a/packages/auditor-backoffice-ui/src/scss/_card.scss +++ b/packages/auditor-backoffice-ui/src/scss/_card.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_custom-calendar.scss b/packages/auditor-backoffice-ui/src/scss/_custom-calendar.scss index 34c40092b..62414a00a 100644 --- a/packages/auditor-backoffice-ui/src/scss/_custom-calendar.scss +++ b/packages/auditor-backoffice-ui/src/scss/_custom-calendar.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_footer.scss b/packages/auditor-backoffice-ui/src/scss/_footer.scss index 5855af742..7e90c40cc 100644 --- a/packages/auditor-backoffice-ui/src/scss/_footer.scss +++ b/packages/auditor-backoffice-ui/src/scss/_footer.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_form.scss b/packages/auditor-backoffice-ui/src/scss/_form.scss index bd28a17cf..126d3d0cc 100644 --- a/packages/auditor-backoffice-ui/src/scss/_form.scss +++ b/packages/auditor-backoffice-ui/src/scss/_form.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_hero-bar.scss b/packages/auditor-backoffice-ui/src/scss/_hero-bar.scss index 0276468d7..cb3f438e9 100644 --- a/packages/auditor-backoffice-ui/src/scss/_hero-bar.scss +++ b/packages/auditor-backoffice-ui/src/scss/_hero-bar.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_loading.scss b/packages/auditor-backoffice-ui/src/scss/_loading.scss index d88d8c355..32f64f276 100644 --- a/packages/auditor-backoffice-ui/src/scss/_loading.scss +++ b/packages/auditor-backoffice-ui/src/scss/_loading.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_main-section.scss b/packages/auditor-backoffice-ui/src/scss/_main-section.scss index 5a8b20ba0..444af5235 100644 --- a/packages/auditor-backoffice-ui/src/scss/_main-section.scss +++ b/packages/auditor-backoffice-ui/src/scss/_main-section.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_misc.scss b/packages/auditor-backoffice-ui/src/scss/_misc.scss index 045d087e2..a0dbc64fc 100644 --- a/packages/auditor-backoffice-ui/src/scss/_misc.scss +++ b/packages/auditor-backoffice-ui/src/scss/_misc.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_modal.scss b/packages/auditor-backoffice-ui/src/scss/_modal.scss index b2bfd3e9e..d2565e7c7 100644 --- a/packages/auditor-backoffice-ui/src/scss/_modal.scss +++ b/packages/auditor-backoffice-ui/src/scss/_modal.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_nav-bar.scss b/packages/auditor-backoffice-ui/src/scss/_nav-bar.scss index 406e0392f..4c0e2f5cc 100644 --- a/packages/auditor-backoffice-ui/src/scss/_nav-bar.scss +++ b/packages/auditor-backoffice-ui/src/scss/_nav-bar.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_table.scss b/packages/auditor-backoffice-ui/src/scss/_table.scss index e4fbfc7b3..6c7765a74 100644 --- a/packages/auditor-backoffice-ui/src/scss/_table.scss +++ b/packages/auditor-backoffice-ui/src/scss/_table.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_theme-default.scss b/packages/auditor-backoffice-ui/src/scss/_theme-default.scss index e74ece0e9..f34497bde 100644 --- a/packages/auditor-backoffice-ui/src/scss/_theme-default.scss +++ b/packages/auditor-backoffice-ui/src/scss/_theme-default.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_tiles.scss b/packages/auditor-backoffice-ui/src/scss/_tiles.scss index 94dd6c21d..75bc6b94e 100644 --- a/packages/auditor-backoffice-ui/src/scss/_tiles.scss +++ b/packages/auditor-backoffice-ui/src/scss/_tiles.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/_title-bar.scss b/packages/auditor-backoffice-ui/src/scss/_title-bar.scss index bac3f6b42..5de384a32 100644 --- a/packages/auditor-backoffice-ui/src/scss/_title-bar.scss +++ b/packages/auditor-backoffice-ui/src/scss/_title-bar.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/fonts/nunito.css b/packages/auditor-backoffice-ui/src/scss/fonts/nunito.css index a578506e8..591fc3da2 100644 --- a/packages/auditor-backoffice-ui/src/scss/fonts/nunito.css +++ b/packages/auditor-backoffice-ui/src/scss/fonts/nunito.css @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/libs/_all.scss b/packages/auditor-backoffice-ui/src/scss/libs/_all.scss index cba6f26eb..ab8030a13 100644 --- a/packages/auditor-backoffice-ui/src/scss/libs/_all.scss +++ b/packages/auditor-backoffice-ui/src/scss/libs/_all.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/scss/main.scss b/packages/auditor-backoffice-ui/src/scss/main.scss index c4be8aa73..4a46472f9 100644 --- a/packages/auditor-backoffice-ui/src/scss/main.scss +++ b/packages/auditor-backoffice-ui/src/scss/main.scss @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/stories.test.ts b/packages/auditor-backoffice-ui/src/stories.test.ts deleted file mode 100644 index abd993550..000000000 --- a/packages/auditor-backoffice-ui/src/stories.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - 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 { setupI18n } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; -import { parseGroupImport } from "@gnu-taler/web-util/browser"; -import * as admin from "./paths/admin/index.stories.js"; -import * as instance from "./paths/instance/index.stories.js"; - -setupI18n("en", { en: {} }); - -describe("All the examples:", () => { - const cms = parseGroupImport({ admin, instance }); - cms.forEach((group) => { - describe(`Example for group: ${group.title}`, () => { - group.list.forEach((component) => { - describe(`Component: ${component.name}`, () => { - component.examples.forEach((example) => { - it(`should render example: ${example.name}`, () => { - tests.renderUI(example.render); - }); - }); - }); - }); - }); - }); -}); diff --git a/packages/auditor-backoffice-ui/src/stories.tsx b/packages/auditor-backoffice-ui/src/stories.tsx deleted file mode 100644 index 8bb06b8cb..000000000 --- a/packages/auditor-backoffice-ui/src/stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 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 { strings } from "./i18n/strings.js"; - -import * as admin from "./paths/admin/index.stories.js"; -import * as instance from "./paths/instance/index.stories.js"; -import * as components from "./components/index.stories.js"; - -import { renderStories } from "@gnu-taler/web-util/browser"; - -import "./scss/main.scss"; - -function SortStories(a: any, b: any): number { - return (a?.order ?? 0) - (b?.order ?? 0); -} - -function main(): void { - renderStories( - { admin, instance, components }, - { - strings, - }, - ); -} - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", main); -} else { - main(); -} diff --git a/packages/auditor-backoffice-ui/src/sw.js b/packages/auditor-backoffice-ui/src/sw.js deleted file mode 100644 index bf52db6fa..000000000 --- a/packages/auditor-backoffice-ui/src/sw.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 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 { getFiles, setupPrecaching, setupRouting } from 'preact-cli/sw/'; - -// setupRouting(); -// setupPrecaching(getFiles()); diff --git a/packages/auditor-backoffice-ui/src/utils/amount.ts b/packages/auditor-backoffice-ui/src/utils/amount.ts index 475489d3e..0796087ac 100644 --- a/packages/auditor-backoffice-ui/src/utils/amount.ts +++ b/packages/auditor-backoffice-ui/src/utils/amount.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -12,13 +12,13 @@ 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 { amountFractionalBase, AmountJson, Amounts, } from "@gnu-taler/taler-util"; -import { MerchantBackend } from "../declaration.js"; +import { AuditorBackend } from "../declaration.js"; /** * merge refund with the same description and a difference less than one minute @@ -26,7 +26,7 @@ import { MerchantBackend } from "../declaration.js"; * @param cur new refund to add to the list * @returns list with the new refund, may be merged with the last */ -export function mergeRefunds( +/*export function mergeRefunds( prev: MerchantBackend.Orders.RefundDetails[], cur: MerchantBackend.Orders.RefundDetails, ): MerchantBackend.Orders.RefundDetails[] { @@ -69,3 +69,4 @@ export function rate(a: AmountJson, b: AmountJson): number { function toFloat(amount: AmountJson): number { return amount.value + amount.fraction / amountFractionalBase; } +*/
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/utils/constants.ts b/packages/auditor-backoffice-ui/src/utils/constants.ts index 7c4e288b3..e8db927c4 100644 --- a/packages/auditor-backoffice-ui/src/utils/constants.ts +++ b/packages/auditor-backoffice-ui/src/utils/constants.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/utils/regex.test.ts b/packages/auditor-backoffice-ui/src/utils/regex.test.ts index 984f1a472..78f2ef5ae 100644 --- a/packages/auditor-backoffice-ui/src/utils/regex.test.ts +++ b/packages/auditor-backoffice-ui/src/utils/regex.test.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/auditor-backoffice-ui/src/utils/table.ts b/packages/auditor-backoffice-ui/src/utils/table.ts index db2b2021c..1322ad804 100644 --- a/packages/auditor-backoffice-ui/src/utils/table.ts +++ b/packages/auditor-backoffice-ui/src/utils/table.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -13,14 +13,14 @@ 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 { WithId } from "../declaration.js"; - +*/ /** * * @author Sebastian Javier Marchano (sebasjm) */ - +/* export interface Actions<T extends WithId> { element: T; type: "DELETE" | "UPDATE"; @@ -40,7 +40,7 @@ export function buildActions<T extends WithId>( .filter(notEmpty) .map((id) => ({ element: id, type: action })); } - +*/ /** * For any object or array, return the same object if is not empty. * not empty: @@ -48,10 +48,10 @@ export function buildActions<T extends WithId>( * - for objects: at least one property not undefined * @param obj * @returns - */ + *//* export function undefinedIfEmpty< T extends Record<string, unknown> | Array<unknown>, >(obj: T | undefined): T | undefined { if (obj === undefined) return undefined; return Object.values(obj).some((v) => v !== undefined) ? obj : undefined; -} +}*/ diff --git a/packages/auditor-backoffice-ui/src/utils/types.ts b/packages/auditor-backoffice-ui/src/utils/types.ts index 0d249f3c4..f96606a16 100644 --- a/packages/auditor-backoffice-ui/src/utils/types.ts +++ b/packages/auditor-backoffice-ui/src/utils/types.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 diff --git a/packages/bank-ui/package.json b/packages/bank-ui/package.json index db89e58be..65281bf2b 100644 --- a/packages/bank-ui/package.json +++ b/packages/bank-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/bank-ui", - "version": "0.11.4", + "version": "0.12.2", "license": "AGPL-3.0-OR-LATER", "type": "module", "scripts": { diff --git a/packages/bank-ui/src/hooks/account.ts b/packages/bank-ui/src/hooks/account.ts index 43d43a3f2..b18aad9ad 100644 --- a/packages/bank-ui/src/hooks/account.ts +++ b/packages/bank-ui/src/hooks/account.ts @@ -292,7 +292,7 @@ export function useTransactions(account: string, initial?: number) { TalerCoreBankResultByMethod<"getTransactions">, TalerHttpError >([account, token, offset, "getTransactions"], fetcher, { - refreshInterval: 0, + refreshInterval: 10000, refreshWhenHidden: false, refreshWhenOffline: false, // revalidateOnMount: false, diff --git a/packages/bank-ui/src/hooks/preferences.ts b/packages/bank-ui/src/hooks/preferences.ts index 9c60456c7..a03234634 100644 --- a/packages/bank-ui/src/hooks/preferences.ts +++ b/packages/bank-ui/src/hooks/preferences.ts @@ -19,7 +19,6 @@ import { TranslatedString, buildCodecForObject, codecForBoolean, - codecForNumber, } from "@gnu-taler/taler-util"; import { buildStorageKey, @@ -31,9 +30,9 @@ interface Preferences { showWithdrawalSuccess: boolean; showDemoDescription: boolean; showInstallWallet: boolean; - maxWithdrawalAmount: number; - fastWithdrawal: boolean; showDebugInfo: boolean; + fastWithdrawalForm: boolean; + showCopyAccount: boolean; } export const codecForPreferences = (): Codec<Preferences> => @@ -41,18 +40,18 @@ export const codecForPreferences = (): Codec<Preferences> => .property("showWithdrawalSuccess", codecForBoolean()) .property("showDemoDescription", codecForBoolean()) .property("showInstallWallet", codecForBoolean()) - .property("fastWithdrawal", codecForBoolean()) .property("showDebugInfo", codecForBoolean()) - .property("maxWithdrawalAmount", codecForNumber()) - .build("Settings"); + .property("fastWithdrawalForm", codecForBoolean()) + .property("showCopyAccount", codecForBoolean()) + .build("Preferences"); const defaultPreferences: Preferences = { showWithdrawalSuccess: true, showDemoDescription: true, showInstallWallet: true, - maxWithdrawalAmount: 25, - fastWithdrawal: false, showDebugInfo: false, + fastWithdrawalForm: false, + showCopyAccount: false, }; const BANK_PREFERENCES_KEY = buildStorageKey( @@ -82,11 +81,12 @@ export function usePreferences(): [ export function getAllBooleanPreferences(): Array<keyof Preferences> { return [ - "fastWithdrawal", "showDebugInfo", "showDemoDescription", "showInstallWallet", "showWithdrawalSuccess", + "fastWithdrawalForm", + "showCopyAccount", ]; } @@ -95,16 +95,16 @@ export function getLabelForPreferences( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString { switch (k) { - case "maxWithdrawalAmount": - return i18n.str`Max withdrawal amount`; case "showWithdrawalSuccess": return i18n.str`Show withdrawal confirmation`; + case "fastWithdrawalForm": + return i18n.str`Withdraw without setting amount`; + case "showCopyAccount": + return i18n.str`Show copy account letter`; case "showDemoDescription": return i18n.str`Show demo description`; case "showInstallWallet": return i18n.str`Show install wallet first`; - case "fastWithdrawal": - return i18n.str`Set the withdrawal amount in the wallet`; case "showDebugInfo": return i18n.str`Show debug info`; } diff --git a/packages/bank-ui/src/i18n/es.po b/packages/bank-ui/src/i18n/es.po index 39527f1dd..fdfda5638 100644 --- a/packages/bank-ui/src/i18n/es.po +++ b/packages/bank-ui/src/i18n/es.po @@ -14,8 +14,8 @@ msgstr "" "Project-Id-Version: Taler Wallet\n" "Report-Msgid-Bugs-To: taler@gnu.org\n" "POT-Creation-Date: 2016-11-23 00:00+0100\n" -"PO-Revision-Date: 2024-02-13 14:40+0000\n" -"Last-Translator: Stefan Kügel <skuegel@web.de>\n" +"PO-Revision-Date: 2024-06-26 08:05+0000\n" +"Last-Translator: Luis Avalos <avalos.diaz.0577@gmail.com>\n" "Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/" "taler-bank-spa/es/>\n" "Language: es\n" @@ -23,7 +23,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.2.1\n" +"X-Generator: Weblate 5.5.5\n" #: src/utils.ts:137 #, c-format @@ -614,7 +614,7 @@ msgstr "a una billetera %1$s" #: src/pages/PaymentOptions.tsx:95 #, c-format msgid "Withdraw digital money into your mobile wallet or browser extension" -msgstr "Extraer dinero digital a tu billetera móvil o extesión web" +msgstr "Extraer dinero digital a tu billetera móvil o extensión web" #: src/pages/PaymentOptions.tsx:109 #, c-format diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts index 8a9471ef4..e96702652 100644 --- a/packages/bank-ui/src/pages/AccountPage/index.ts +++ b/packages/bank-ui/src/pages/AccountPage/index.ts @@ -26,6 +26,7 @@ import { LoginForm } from "../LoginForm.js"; import { useComponentState } from "./state.js"; import { InvalidIbanView, ReadyView } from "./views.js"; import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { Fragment } from "preact"; export interface Props { account: string; @@ -125,7 +126,11 @@ const viewMapping: utils.StateViewMap<State> = { loading: Loading, login: LoginForm, "invalid-iban": InvalidIbanView, - "loading-error": ErrorLoadingWithDebug, + "loading-error": (d) => { + return Fragment({ + children: [ErrorLoadingWithDebug({ error: d.error }), LoginForm({})], + })!; + }, ready: ReadyView, }; diff --git a/packages/bank-ui/src/pages/AccountPage/views.tsx b/packages/bank-ui/src/pages/AccountPage/views.tsx index 42892f536..93a769147 100644 --- a/packages/bank-ui/src/pages/AccountPage/views.tsx +++ b/packages/bank-ui/src/pages/AccountPage/views.tsx @@ -42,7 +42,7 @@ function ShowDemoInfo({ if (!settings.showDemoDescription) return <Fragment />; return ( <Attention - title={i18n.str`This is a demo bank`} + title={i18n.str`This is a demo`} onClose={() => { updateSettings("showDemoDescription", false); }} @@ -59,7 +59,7 @@ function ShowDemoInfo({ </i18n.Translate> ) : ( <i18n.Translate> - This part of the demo shows how a bank that supports Taler directly + Here you will be able to see how a bank that supports Taler directly would work. </i18n.Translate> )} diff --git a/packages/bank-ui/src/pages/BankFrame.tsx b/packages/bank-ui/src/pages/BankFrame.tsx index db757ee07..f3c6817d3 100644 --- a/packages/bank-ui/src/pages/BankFrame.tsx +++ b/packages/bank-ui/src/pages/BankFrame.tsx @@ -64,6 +64,8 @@ export function BankFrame({ const settings = useSettingsContext(); const [preferences, updatePreferences] = usePreferences(); const [, , resetBankState] = useBankState(); + const d = useBankCoreApiContext(); + const config = d === undefined ? undefined : d.config; const [error, resetError] = useErrorBoundary(); @@ -90,7 +92,7 @@ export function BankFrame({ > <div class="bg-indigo-600 pb-32"> <Header - title="Bank" + title={config?.bank_name ?? "Bank"} iconLinkURL={settings.iconLinkURL ?? "#"} profileURL={routeAccountDetails?.url({})} notificationURL={ @@ -160,7 +162,6 @@ export function BankFrame({ <div class="fixed z-20 top-14 w-full"> <div class="mx-auto w-4/5"> <ToastBanner /> - {/* <Attention type="success" title={"hola" as TranslatedString} onClose={() => { }} /> */} </div> </div> @@ -257,7 +258,7 @@ function AppActivity(): VNode { return; } /** - * all of this are ignored + * all of these are ignored */ case ObservabilityEventType.DbQueryStart: case ObservabilityEventType.DbQueryFinishSuccess: @@ -274,6 +275,7 @@ function AppActivity(): VNode { case ObservabilityEventType.CryptoFinishSuccess: case ObservabilityEventType.CryptoFinishError: case ObservabilityEventType.Message: + case ObservabilityEventType.DeclareConcernsTransaction: return; default: { assertUnreachable(ev); diff --git a/packages/bank-ui/src/pages/OperationState/index.ts b/packages/bank-ui/src/pages/OperationState/index.ts index 38f698a04..2dce76a62 100644 --- a/packages/bank-ui/src/pages/OperationState/index.ts +++ b/packages/bank-ui/src/pages/OperationState/index.ts @@ -41,6 +41,7 @@ export interface Props { onAuthorizationRequired: () => void; routeClose: RouteDefinition; onAbort: () => void; + focus?: boolean; routeHere: RouteDefinition<{ wopid: string }>; } @@ -79,6 +80,7 @@ export namespace State { status: "ready"; error: undefined; uri: WithdrawUriResult; + focus?: boolean; onAbort: () => Promise< TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined >; diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts index 32d4fea7a..6856f5f59 100644 --- a/packages/bank-ui/src/pages/OperationState/state.ts +++ b/packages/bank-ui/src/pages/OperationState/state.ts @@ -34,15 +34,18 @@ import { useSessionState } from "../../hooks/session.js"; import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { Props, State } from "./index.js"; +import { useSettingsContext } from "../../context/settings.js"; export function useComponentState({ currency, routeClose, onAbort, + focus, routeHere, onAuthorizationRequired, }: Props): utils.RecursiveState<State> { - const [settings] = usePreferences(); + const [preference] = usePreferences(); + const settings = useSettingsContext(); const [bankState, updateBankState] = useBankState(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; @@ -53,14 +56,14 @@ export function useComponentState({ const [failure, setFailure] = useState< TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined >(); - const amount = settings.maxWithdrawalAmount; + const amount = settings.defaultSuggestedAmount; async function doSilentStart() { // FIXME: if amount is not enough use balance const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`); if (!creds) return; const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest = - settings.fastWithdrawal + preference.fastWithdrawalForm ? { suggested_amount: Amounts.stringify(parsedAmount), } @@ -81,7 +84,7 @@ export function useComponentState({ if (withdrawalOperationId === undefined) { doSilentStart(); } - }, [settings.fastWithdrawal, amount]); + }, [preference.fastWithdrawalForm, amount]); if (failure) { return { @@ -182,7 +185,7 @@ export function useComponentState({ } if (data.status === "confirmed") { - if (!settings.showWithdrawalSuccess) { + if (!preference.showWithdrawalSuccess) { updateBankState("currentWithdrawalOperationId", undefined); // onClose() } @@ -199,6 +202,7 @@ export function useComponentState({ error: undefined, uri: parsedUri, routeClose, + focus, onAbort: !creds ? async () => { onAbort(); diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx index 62308eca6..88f34ae26 100644 --- a/packages/bank-ui/src/pages/OperationState/views.tsx +++ b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -37,6 +37,7 @@ import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js"; import { State } from "./index.js"; +import { doAutoFocus } from "../PaytoWireTransferForm.js"; export function InvalidPaytoView({ payto }: State.InvalidPayto) { return <div>Payto from server is not valid "{payto}"</div>; @@ -338,7 +339,11 @@ export function ConfirmedView({ routeClose }: State.Confirmed) { ); } -export function ReadyView({ uri, onAbort: doAbort }: State.Ready): VNode { +export function ReadyView({ + uri, + focus, + onAbort: doAbort, +}: State.Ready): VNode { const { i18n } = useTranslationContext(); const walletInegrationApi = useTalerWalletIntegrationAPI(); const [notification, notify, errorHandler] = useLocalNotification(); @@ -387,60 +392,79 @@ export function ReadyView({ uri, onAbort: doAbort }: State.Ready): VNode { <Fragment> <LocalNotificationBanner notification={notification} /> - <div class="flex justify-end mt-4"> - <button - type="button" - name="cancel" - class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" - onClick={onAbort} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - </div> - - <div class="bg-white shadow sm:rounded-lg mt-4"> - <div class="p-4"> + <div class="bg-white shadow-xl sm:rounded-lg"> + <div class="px-4 py-5 sm:p-6"> <h3 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>On this device</i18n.Translate> + <i18n.Translate> + If you have a Taler wallet installed on this device + </i18n.Translate> </h3> - <div class="mt-2 sm:flex sm:items-start sm:justify-between"> - <div class="max-w-xl text-sm text-gray-500"> - <p> - <i18n.Translate> - If you are using a web browser on desktop you can also - </i18n.Translate> - </p> - </div> - <div class="mt-5 sm:ml-6 sm:mt-0 sm:flex sm:flex-shrink-0 sm:items-center"> + <div class="mt-4 mb-4 text-sm text-gray-500"> + <p> + <i18n.Translate> + Your wallet will display the details of the transaction + including the fees (if applicable). If you do not yet have a + wallet, please follow the instructions + </i18n.Translate>{" "} <a - href={talerWithdrawUri} - name="start" - class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="font-semibold text-indigo-600 hover:text-indigo-900" + name="wallet page" + href="https://taler.net/en/wallet.html" > - <i18n.Translate>Start</i18n.Translate> + <i18n.Translate>on this page</i18n.Translate> </a> - </div> + . + </p> + </div> + <div class="flex items-center justify-between gap-x-6 pt-2 mt-2 "> + <button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + // class="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-black shadow-sm " + onClick={onAbort} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + + <a + href={talerWithdrawUri} + name="withdraw" + class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Withdraw</i18n.Translate> + </a> </div> </div> </div> - <div class="bg-white shadow sm:rounded-lg mt-2"> - <div class="p-4"> + + <div class="bg-white shadow-xl sm:rounded-lg mt-8"> + <div class="px-4 py-5 sm:p-6"> <h3 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>On a mobile phone</i18n.Translate> + <i18n.Translate> + Or if you have the Taler wallet on another device + </i18n.Translate> </h3> - <div class="mt-2 sm:flex sm:items-start sm:justify-between"> - <div class="max-w-xl text-sm text-gray-500"> - <p> - <i18n.Translate> - Scan the QR code with your mobile device. - </i18n.Translate> - </p> - </div> + <div class="mt-4 max-w-xl text-sm text-gray-500"> + <i18n.Translate> + Scan the QR below to start the withdrawal. + </i18n.Translate> </div> <div class="mt-2 max-w-md ml-auto mr-auto"> <QR text={talerWithdrawUri} /> </div> </div> + <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <button + type="button" + // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + class="text-sm font-semibold leading-6 text-gray-900" + // handler={onAbortHandler} + onClick={onAbort} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + </div> </div> </Fragment> ); diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx index 386fe31bc..9529e3843 100644 --- a/packages/bank-ui/src/pages/PaymentOptions.tsx +++ b/packages/bank-ui/src/pages/PaymentOptions.tsx @@ -48,7 +48,7 @@ function ShowOperationPendingTag({ if (!loading && !pending && onOperationAlreadyCompleted) { onOperationAlreadyCompleted(); } - }, [pending]); + }, [loading, pending]); if (error || !pending) { return <Fragment />; diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx index 90b41d331..0fb8c0ac1 100644 --- a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -79,6 +79,7 @@ export function PaytoWireTransferForm({ routeHere, onAuthorizationRequired, limit, + balance, }: Props): VNode { const [inputType, setInputType] = useState<"form" | "payto" | "qr">("form"); const isRawPayto = inputType !== "form"; @@ -116,6 +117,11 @@ export function PaytoWireTransferForm({ ? Amounts.zeroOfCurrency(config.currency) : Amounts.parseOrThrow(config.wire_transfer_fees); + const limitWithFee = + Amounts.cmp(limit, wireFee) === 1 + ? Amounts.sub(limit, wireFee).amount + : Amounts.zeroOfAmount(limit); + const errorsWire = undefinedIfEmpty({ account: !account ? i18n.str`Required` @@ -129,7 +135,7 @@ export function PaytoWireTransferForm({ ? i18n.str`Required` : !parsedAmount ? i18n.str`Not valid` - : validateAmount(parsedAmount, limit, wireFee, i18n), + : validateAmount(parsedAmount, limitWithFee, i18n), }); const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); @@ -139,7 +145,7 @@ export function PaytoWireTransferForm({ ? i18n.str`Required` : !parsed ? i18n.str`Does not follow the pattern` - : validateRawPayto(parsed, limit, wireFee, url.host, i18n, paytoType), + : validateRawPayto(parsed, limitWithFee, url.host, i18n, paytoType), }); async function doSend() { @@ -627,6 +633,17 @@ export function PaytoWireTransferForm({ </div> </div> )} + {Amounts.cmp(limitWithFee, balance) > 0 ? ( + <p class="mt-2 text-sm text-gray-900"> + <i18n.Translate> + You can transfer{" "} + <RenderAmount + value={limitWithFee} + spec={config.currency_specification} + /> + </i18n.Translate> + </p> + ) : undefined} </div> {Amounts.isZero(wireFee) ? undefined : ( <div class="px-4 my-4"> @@ -800,7 +817,6 @@ export function RenderAmount({ function validateRawPayto( parsed: PaytoUri, limit: AmountJson, - fee: AmountJson, host: string, i18n: InternationalizationAPI, type: "iban" | "x-taler-bank", @@ -844,7 +860,7 @@ function validateRawPayto( if (!amount) { return i18n.str`The "amount" parameter is not valid`; } - result = validateAmount(amount, limit, fee, i18n); + result = validateAmount(amount, limit, i18n); if (result) return result; if (!parsed.params.message) { @@ -860,7 +876,6 @@ function validateRawPayto( function validateAmount( amount: AmountJson, limit: AmountJson, - fee: AmountJson, i18n: InternationalizationAPI, ): TranslatedString | undefined { if (amount.currency !== limit.currency) { @@ -869,8 +884,7 @@ function validateAmount( if (Amounts.isZero(amount)) { return i18n.str`Can't transfer zero amount`; } - const amountWithFee = Amounts.add(amount, fee).amount; - if (Amounts.cmp(limit, amountWithFee) === -1) { + if (Amounts.cmp(limit, amount) === -1) { return i18n.str`Balance is not enough`; } return undefined; diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx index 2a21295c7..0d14f52d8 100644 --- a/packages/bank-ui/src/pages/QrCodeSection.tsx +++ b/packages/bank-ui/src/pages/QrCodeSection.tsx @@ -76,11 +76,12 @@ export function QrCodeSection({ return ( <Fragment> <LocalNotificationBanner notification={notification} /> + <div class="bg-white shadow-xl sm:rounded-lg"> <div class="px-4 py-5 sm:p-6"> <h3 class="text-base font-semibold leading-6 text-gray-900"> <i18n.Translate> - If you have a Taler wallet installed in this device + If you have a Taler wallet installed on this device </i18n.Translate> </h3> <div class="mt-4 mb-4 text-sm text-gray-500"> @@ -88,19 +89,19 @@ export function QrCodeSection({ <i18n.Translate> Your wallet will display the details of the transaction including the fees (if applicable). If you do not yet have a - wallet, please follow the instructions on - </i18n.Translate> + wallet, please follow the instructions + </i18n.Translate>{" "} <a - class="font-semibold text-gray-500 hover:text-gray-400" + class="font-semibold text-indigo-600 hover:text-indigo-900" name="wallet page" href="https://taler.net/en/wallet.html" > - <i18n.Translate>this page</i18n.Translate> + <i18n.Translate>on this page</i18n.Translate> </a> . </p> </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 pt-2 mt-2 "> + <div class="flex items-center justify-between gap-x-6 pt-2 mt-2 "> <Button type="button" name="cancel" @@ -124,7 +125,7 @@ export function QrCodeSection({ <div class="px-4 py-5 sm:p-6"> <h3 class="text-base font-semibold leading-6 text-gray-900"> <i18n.Translate> - Or if you have the Taler wallet in another device + Or if you have the Taler wallet on another device </i18n.Translate> </h3> <div class="mt-4 max-w-xl text-sm text-gray-500"> diff --git a/packages/bank-ui/src/pages/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx index 624890468..e4ca13ed6 100644 --- a/packages/bank-ui/src/pages/SolveChallengePage.tsx +++ b/packages/bank-ui/src/pages/SolveChallengePage.tsx @@ -206,7 +206,12 @@ export function SolveChallengePage({ case "update-password": return await api.updatePassword(creds, ch.request, ch.id); case "create-transaction": - return await api.createTransaction(creds, ch.request, undefined, ch.id); + return await api.createTransaction( + creds, + ch.request, + undefined, + ch.id, + ); case "confirm-withdrawal": return await api.confirmWithdrawalById(creds, ch.request, ch.id); case "create-cashout": @@ -694,10 +699,14 @@ function ShowWithdrawalDetails({ id }: { id: string }): VNode { <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <RenderAmount - value={Amounts.parseOrThrow(details.body.amount)} - spec={config.currency_specification} - /> + {details.body.amount !== undefined ? ( + <RenderAmount + value={Amounts.parseOrThrow(details.body.amount)} + spec={config.currency_specification} + /> + ) : ( + <i18n.Translate>No amount specified yet.</i18n.Translate> + )} </dd> </div> {details.body.selected_reserve_pub !== undefined && ( diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx index 39dea018f..6b1043424 100644 --- a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -46,6 +46,7 @@ import { RenderAmount, doAutoFocus, } from "./PaytoWireTransferForm.js"; +import { useSettingsContext } from "../context/settings.js"; const RefAmount = forwardRef(InputAmount); @@ -65,7 +66,8 @@ function OldWithdrawalForm({ routeCancel: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); - const [settings] = usePreferences(); + const settings = useSettingsContext(); + const [preference] = usePreferences(); // const walletInegrationApi = useTalerWalletIntegrationAPI() // const { navigateTo } = useNavigationContext(); @@ -80,7 +82,7 @@ function OldWithdrawalForm({ const creds = credentials.status !== "loggedIn" ? undefined : credentials; const [amountStr, setAmountStr] = useState<string | undefined>( - `${settings.maxWithdrawalAmount}`, + `${settings.defaultSuggestedAmount ?? 1}`, ); const [notification, notify, handleError] = useLocalNotification(); @@ -143,7 +145,7 @@ function OldWithdrawalForm({ if (!parsedAmount || !creds) return; await handleError(async () => { const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest = - settings.fastWithdrawal + preference.fastWithdrawalForm ? { suggested_amount: Amounts.stringify(parsedAmount), } @@ -241,9 +243,9 @@ function OldWithdrawalForm({ </i18n.Translate> </p> {Amounts.cmp(limit, balance) > 0 ? ( - <p class="mt-2 text-sm text-gray-500"> + <p class="mt-2 text-sm text-gray-900"> <i18n.Translate> - Your account allows you to withdraw{" "} + You can withdraw up to{" "} <RenderAmount value={limit} spec={config.currency_specification} @@ -347,7 +349,7 @@ export function WalletWithdrawForm({ routeCancel: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); - const [settings, updateSettings] = usePreferences(); + const [pref, updatePref] = usePreferences(); return ( <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> @@ -357,18 +359,18 @@ export function WalletWithdrawForm({ </h2> <p class="mt-1 text-sm text-gray-500"> <i18n.Translate> - After using your wallet you will need to confirm or cancel the + After using your wallet you will need to authorize or cancel the operation on this site. </i18n.Translate> </p> </div> <div class="col-span-2"> - {settings.showInstallWallet && ( + {pref.showInstallWallet && ( <Attention title={i18n.str`You need a Taler wallet`} onClose={() => { - updateSettings("showInstallWallet", false); + updatePref("showInstallWallet", false); }} > <i18n.Translate> @@ -386,7 +388,7 @@ export function WalletWithdrawForm({ </Attention> )} - {!settings.fastWithdrawal ? ( + {!pref.fastWithdrawalForm ? ( <OldWithdrawalForm focus={focus} routeOperationDetails={routeOperationDetails} @@ -397,6 +399,7 @@ export function WalletWithdrawForm({ /> ) : ( <OperationState + focus={focus} currency={limit.currency} onAuthorizationRequired={onAuthorizationRequired} routeClose={routeCancel} diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx index f45390938..817145702 100644 --- a/packages/bank-ui/src/pages/WireTransfer.tsx +++ b/packages/bank-ui/src/pages/WireTransfer.tsx @@ -62,7 +62,12 @@ export function WireTransfer({ return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} />; + return ( + <Fragment> + <ErrorLoadingWithDebug error={result} /> + <LoginForm currentUser={account} /> + </Fragment> + ); } if (result.type === "fail") { switch (result.case) { diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx index 853dd7bae..25fa36d55 100644 --- a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx +++ b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -17,6 +17,7 @@ import { AbsoluteTime, AmountJson, + Amounts, HttpStatusCode, PaytoUri, PaytoUriIBAN, @@ -51,7 +52,7 @@ interface Props { account: PaytoUri; reserve: string; username: string; - amount: AmountJson; + amount?: AmountJson; }; onAuthorizationRequired: () => void; } @@ -79,6 +80,11 @@ export function WithdrawalConfirmationQuestion({ lib: { bank: api }, } = useBankCoreApiContext(); + const wireFee = + config.wire_transfer_fees === undefined + ? Amounts.zeroOfCurrency(config.currency) + : Amounts.parseOrThrow(config.wire_transfer_fees); + async function doTransfer() { await handleError(async () => { if (!creds) return; @@ -351,12 +357,35 @@ export function WithdrawalConfirmationQuestion({ <i18n.Translate>Amount</i18n.Translate> </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <RenderAmount - value={details.amount} - spec={config.currency_specification} - /> + {details.amount !== undefined ? ( + <RenderAmount + value={details.amount} + spec={config.currency_specification} + /> + ) : ( + <i18n.Translate> + No amount specified yet. + </i18n.Translate> + )} </dd> </div> + {Amounts.isZero(wireFee) ? undefined : ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Cost</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={wireFee} + negative + withColor + spec={config.currency_specification} + /> + </dd> + </div> + </Fragment> + )} </dl> </div> </div> diff --git a/packages/bank-ui/src/pages/WithdrawalQRCode.tsx b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx index b61f0cc8f..fc6d20631 100644 --- a/packages/bank-ui/src/pages/WithdrawalQRCode.tsx +++ b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx @@ -21,6 +21,7 @@ import { WithdrawUriResult, assertUnreachable, parsePaytoUri, + stringifyWithdrawUri, } from "@gnu-taler/taler-util"; import { Attention, @@ -122,6 +123,7 @@ export function WithdrawalQRCode({ </div> ); } + const talerWithdrawUri = stringifyWithdrawUri(withdrawUri); if (data.status === "confirmed") { return ( @@ -161,13 +163,20 @@ export function WithdrawalQRCode({ </div> </div> </div> - <div class="mt-5 sm:mt-6"> + <div class="mt-5 sm:mt-6 items-center justify-between gap-x-2 flex"> <a href={routeClose.url({})} name="done" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="inline-flex justify-center rounded-md bg-white-600 px-3 py-2 text-sm font-semibold text-black shadow-sm " + > + <i18n.Translate>Close</i18n.Translate> + </a> + <a + href={talerWithdrawUri} + name="done" + class="inline-flex justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" > - <i18n.Translate>Done</i18n.Translate> + <i18n.Translate>Go to wallet</i18n.Translate> </a> </div> </div> @@ -239,7 +248,7 @@ export function WithdrawalQRCode({ username: data.username, account, reserve: data.selected_reserve_pub, - amount: Amounts.parseOrThrow(data.amount), + amount: !data.amount ? undefined : Amounts.parseOrThrow(data.amount), }} onAuthorizationRequired={onAuthorizationRequired} onAborted={() => { diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx index 0e2144d77..f602c67df 100644 --- a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx +++ b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -22,7 +22,7 @@ import { TalerErrorCode, TranslatedString, assertUnreachable, - parsePaytoUri + parsePaytoUri, } from "@gnu-taler/taler-util"; import { CopyButton, @@ -43,6 +43,7 @@ import { useSessionState } from "../../hooks/session.js"; import { LoginForm } from "../LoginForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; import { AccountForm } from "../admin/AccountForm.js"; +import { usePreferences } from "../../hooks/preferences.js"; export function ShowAccountDetails({ account, @@ -68,6 +69,7 @@ export function ShowAccountDetails({ account: string; }): VNode { const { i18n } = useTranslationContext(); + const [preferences] = usePreferences(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const { @@ -89,7 +91,12 @@ export function ShowAccountDetails({ return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} />; + return ( + <Fragment> + <ErrorLoadingWithDebug error={result} /> + <LoginForm currentUser={account} /> + </Fragment> + ); } if (result.type === "fail") { switch (result.case) { @@ -202,16 +209,18 @@ export function ShowAccountDetails({ const url = bank.getRevenueAPI(account); const baseURL = url.href; - const revenueURL = new URL(baseURL) + const revenueURL = new URL(baseURL); revenueURL.username = account; - revenueURL.password = creds?.token ?? "" + revenueURL.password; const ac = parsePaytoUri(result.body.payto_uri); const payto = !ac?.isKnown ? undefined : ac; - const accountLetter : AccountLetter | undefined = !payto + const accountLetter: AccountLetter | undefined = !payto ? undefined : { - accountURI: result.body.payto_uri, infoURL: revenueURL.href - } + accountURI: result.body.payto_uri, + infoURL: revenueURL.href, + accountToken: creds?.token, + }; return ( <Fragment> @@ -339,15 +348,21 @@ export function ShowAccountDetails({ {i18n.str`IBAN`} </label> <div class="mt-2"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" - disabled={true} - value={payto.iban} - autocomplete="off" - /> + <div class="flex justify-between"> + <input + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="iban" + id="iban" + disabled={true} + value={payto.iban} + autocomplete="off" + /> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => payto.iban} + /> + </div> </div> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> @@ -359,30 +374,70 @@ export function ShowAccountDetails({ } case "x-taler-bank": { return ( - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="account-name" - > - {i18n.str`Account name`} - </label> - <div class="mt-2"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="account-name" - id="account-name" - disabled={true} - value={payto.account} - autocomplete="off" - /> + <Fragment> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="account-host" + > + {i18n.str`Account name`} + </label> + <div class="mt-2"> + <div class="flex justify-between"> + <input + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="account-host" + id="account-host" + disabled={true} + value={payto.host} + autocomplete="off" + /> + </div> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => payto.host} + /> + </div> + + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Bank host where the service is located. + </i18n.Translate> + </p> </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Bank account identifier for wire transfers. - </i18n.Translate> - </p> - </div> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="account-name" + > + {i18n.str`Account name`} + </label> + <div class="mt-2"> + <div class="flex justify-between"> + <input + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="account-name" + id="account-name" + disabled={true} + value={payto.account} + autocomplete="off" + /> + </div> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => payto.account} + /> + </div> + + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Bank account identifier for wire transfers. + </i18n.Translate> + </p> + </div> + </Fragment> ); } case "bitcoin": { @@ -401,9 +456,13 @@ export function ShowAccountDetails({ name="iban" id="iban" disabled={true} - value={"DE1231231231"} + value={"asd"} autocomplete="off" /> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => "Asd"} + /> </div> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> @@ -424,15 +483,21 @@ export function ShowAccountDetails({ {i18n.str`Owner's name`} </label> <div class="mt-2"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" - disabled={true} - value={result.body.name} - autocomplete="off" - /> + <div class="flex justify-between"> + <input + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="iban" + id="iban" + disabled={true} + value={result.body.name} + autocomplete="off" + /> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => result.body.name} + /> + </div> </div> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> @@ -448,15 +513,21 @@ export function ShowAccountDetails({ {i18n.str`Account info URL`} </label> <div class="mt-2"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" - disabled={true} - value={baseURL} - autocomplete="off" - /> + <div class="flex justify-between"> + <input + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="iban" + id="iban" + disabled={true} + value={baseURL} + autocomplete="off" + /> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => baseURL} + /> + </div> </div> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> @@ -475,12 +546,20 @@ export function ShowAccountDetails({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <CopyButton - getContent={() => !accountLetter ? "" : JSON.stringify(accountLetter)} - class="flex text-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - > - <i18n.Translate>Copy</i18n.Translate> - </CopyButton> + <span></span> + + {!preferences.showCopyAccount ? ( + <span /> + ) : ( + <CopyButton + getContent={() => + !accountLetter ? "" : JSON.stringify(accountLetter) + } + class="flex text-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Copy</i18n.Translate> + </CopyButton> + )} </div> </div> )} @@ -488,4 +567,3 @@ export function ShowAccountDetails({ </Fragment> ); } - diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx index dbeebf719..74e39112d 100644 --- a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx +++ b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -74,7 +74,12 @@ export function RemoveAccount({ return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} />; + return ( + <Fragment> + <ErrorLoadingWithDebug error={result} /> + <LoginForm currentUser={account} /> + </Fragment> + ); } if (result.type === "fail") { switch (result.case) { diff --git a/packages/bank-ui/src/settings.json b/packages/bank-ui/src/settings.json index df5fe75ce..f14168e77 100644 --- a/packages/bank-ui/src/settings.json +++ b/packages/bank-ui/src/settings.json @@ -2,6 +2,8 @@ "backendBaseURL": "http://bank.taler.test:1180/", "simplePasswordForRandomAccounts": true, "allowRandomAccountCreation": true, + "fastWithdrawalForm": true, + "defaultSuggestedAmount": 11, "bankName": "Taler DEVELOPMENT Bank", "topNavSites": { "Exchange": "http://Exchnage.taler.test:1180/", diff --git a/packages/bank-ui/src/settings.ts b/packages/bank-ui/src/settings.ts index c085c7cd8..c1e418bc1 100644 --- a/packages/bank-ui/src/settings.ts +++ b/packages/bank-ui/src/settings.ts @@ -20,6 +20,7 @@ import { canonicalizeBaseUrl, codecForBoolean, codecForMap, + codecForNumber, codecForString, codecOptional, } from "@gnu-taler/taler-util"; @@ -45,6 +46,10 @@ export interface UiSettings { // - value: link target, where the user is going to be redirected // default: empty list topNavSites?: Record<string, string>; + // When the withdrawal form use the suggested amount the bank + // will send a default value that the user can change. + // default: 10 + defaultSuggestedAmount?: number; } /** @@ -56,12 +61,14 @@ const defaultSettings: UiSettings = { simplePasswordForRandomAccounts: false, allowRandomAccountCreation: false, topNavSites: {}, + defaultSuggestedAmount: 10, }; const codecForUISettings = (): Codec<UiSettings> => buildCodecForObject<UiSettings>() .property("backendBaseURL", codecOptional(codecForString())) .property("allowRandomAccountCreation", codecOptional(codecForBoolean())) + .property("defaultSuggestedAmount", codecOptional(codecForNumber())) .property( "simplePasswordForRandomAccounts", codecOptional(codecForBoolean()), diff --git a/packages/challenger-ui/package.json b/packages/challenger-ui/package.json index 7cc73771b..d4d047326 100644 --- a/packages/challenger-ui/package.json +++ b/packages/challenger-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/challenger-ui", - "version": "0.11.4", + "version": "0.12.2", "author": "sebasjm", "license": "AGPL-3.0-OR-LATER", "description": "UI for GNU Challenger.", diff --git a/packages/challenger-ui/src/Routing.tsx b/packages/challenger-ui/src/Routing.tsx index 6166f159a..f7488cb8d 100644 --- a/packages/challenger-ui/src/Routing.tsx +++ b/packages/challenger-ui/src/Routing.tsx @@ -23,6 +23,7 @@ import { import { Fragment, VNode, h } from "preact"; import { assertUnreachable } from "@gnu-taler/taler-util"; +import { useErrorBoundary } from "preact/hooks"; import { CheckChallengeIsUpToDate } from "./components/CheckChallengeIsUpToDate.js"; import { SessionId, useSessionState } from "./hooks/session.js"; import { AnswerChallenge } from "./pages/AnswerChallenge.js"; @@ -91,6 +92,9 @@ function PublicRounting(): VNode { const location = useCurrentLocation(publicPages); const { navigateTo } = useNavigationContext(); const { start } = useSessionState(); + useErrorBoundary((e) => { + console.log("error", e); + }); if (location === undefined) { return <NonceNotFound />; diff --git a/packages/challenger-ui/src/app.tsx b/packages/challenger-ui/src/app.tsx index 2b5c5c815..02ec95107 100644 --- a/packages/challenger-ui/src/app.tsx +++ b/packages/challenger-ui/src/app.tsx @@ -41,6 +41,7 @@ import { strings } from "./i18n/strings.js"; import { ChallengerUiSettings, fetchSettings } from "./settings.js"; import { Frame } from "./pages/Frame.js"; import { revalidateChallengeSession } from "./hooks/challenge.js"; + const WITH_LOCAL_STORAGE_CACHE = false; const evictBankSwrCache: CacheEvictor<ChallengerCacheEviction> = { @@ -50,6 +51,10 @@ const evictBankSwrCache: CacheEvictor<ChallengerCacheEviction> = { await Promise.all([revalidateChallengeSession()]); return; } + case ChallengerCacheEviction.SOLVE_CHALLENGE: { + await Promise.all([revalidateChallengeSession()]); + return; + } default: { assertUnreachable(op); } diff --git a/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx b/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx index 70e41bf1e..ebfa57d02 100644 --- a/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx +++ b/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx @@ -46,7 +46,7 @@ export function CheckChallengeIsUpToDate({ onNoInfo, }: Props): VNode { const { state, updateStatus } = useSessionState(); - const { i18n } = useTranslationContext(); + const {i18n} = useTranslationContext(); const sessionId = sessionFromParam ? sessionFromParam @@ -59,7 +59,7 @@ export function CheckChallengeIsUpToDate({ }; const result = useChallengeSession(nonce, sessionId); - console.log("asd"); + if (!sessionId) { onNoInfo(); return <Loading />; diff --git a/packages/challenger-ui/src/context/preferences.ts b/packages/challenger-ui/src/context/preferences.ts new file mode 100644 index 000000000..3188bd71c --- /dev/null +++ b/packages/challenger-ui/src/context/preferences.ts @@ -0,0 +1,87 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + Codec, + TranslatedString, + buildCodecForObject, + codecForBoolean, +} from "@gnu-taler/taler-util"; +import { + buildStorageKey, + useLocalStorage, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; + +interface Preferences { + showChallangeSetup: boolean; + showDebugInfo: boolean; +} + +export const codecForPreferences = (): Codec<Preferences> => + buildCodecForObject<Preferences>() + .property("showChallangeSetup", codecForBoolean()) + .property("showDebugInfo", codecForBoolean()) + .build("Preferences"); + +const defaultPreferences: Preferences = { + showChallangeSetup: false, + showDebugInfo: false, +}; + +const PREFERENCES_KEY = buildStorageKey( + "challenger-preferences", + codecForPreferences(), +); +/** + * User preferences. + * + * @returns tuple of [state, update()] + */ +export function usePreferences(): [ + Readonly<Preferences>, + <T extends keyof Preferences>(key: T, value: Preferences[T]) => void, +] { + const { value, update } = useLocalStorage( + PREFERENCES_KEY, + defaultPreferences, + ); + + function updateField<T extends keyof Preferences>(k: T, v: Preferences[T]) { + const newValue = { ...value, [k]: v }; + update(newValue); + } + return [value, updateField]; +} + +export function getAllBooleanPreferences(): Array<keyof Preferences> { + return [ + "showChallangeSetup", + "showDebugInfo", + ]; +} + +export function getLabelForPreferences( + k: keyof Preferences, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): TranslatedString { + switch (k) { + case "showChallangeSetup": + return i18n.str`Show challenger setup screen`; + case "showDebugInfo": + return i18n.str`Show debug info`; + } +} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/update/index.tsx b/packages/challenger-ui/src/declaration.d.ts index 84cc95e72..581cbcd07 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/transfers/update/index.tsx +++ b/packages/challenger-ui/src/declaration.d.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2022-2024 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -14,13 +14,22 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { h, VNode } from "preact"; - -export default function UpdateTransfer(): VNode { - return <div>order transfer page</div>; +declare module "*.css" { + const mapping: Record<string, string>; + export default mapping; +} +declare module "*.svg" { + const content: string; + export default content; } +declare module "*.jpeg" { + const content: string; + export default content; +} +declare module "*.png" { + const content: string; + export default content; +} + +declare const __VERSION__: string; +declare const __GIT_HASH__: string; diff --git a/packages/challenger-ui/src/hooks/challenge.ts b/packages/challenger-ui/src/hooks/challenge.ts index 846242816..224c60b9b 100644 --- a/packages/challenger-ui/src/hooks/challenge.ts +++ b/packages/challenger-ui/src/hooks/challenge.ts @@ -38,7 +38,7 @@ export function useChallengeSession( lib: { challenger: api }, } = useChallengerApiContext(); - async function fetcher([n, c, r, s]: [string, string, string, string]) { + async function fetcher([n, c, r, s]: [string, string, string, string]): Promise<any> { return await api.login(n, c, r, s); } const { data, error } = useSWR< diff --git a/packages/challenger-ui/src/hooks/session.ts b/packages/challenger-ui/src/hooks/session.ts index ed7ea8986..4dc7e0dc1 100644 --- a/packages/challenger-ui/src/hooks/session.ts +++ b/packages/challenger-ui/src/hooks/session.ts @@ -15,9 +15,11 @@ */ import { + AbsoluteTime, ChallengerApi, Codec, buildCodecForObject, + codecForAbsoluteTime, codecForBoolean, codecForChallengeStatus, codecForNumber, @@ -39,8 +41,10 @@ export type SessionId = { }; export type LastChallengeResponse = { - attemptsLeft: number; - nextSend: string; + sendCodeLeft: number; + changeTargetLeft: number; + checkPinLeft: number; + nextSend: AbsoluteTime; transmitted: boolean; }; @@ -51,8 +55,10 @@ export type SessionState = SessionId & { }; export const codecForLastChallengeResponse = (): Codec<LastChallengeResponse> => buildCodecForObject<LastChallengeResponse>() - .property("attemptsLeft", codecForNumber()) - .property("nextSend", codecForString()) + .property("sendCodeLeft", codecForNumber()) + .property("changeTargetLeft", codecForNumber()) + .property("checkPinLeft", codecForNumber()) + .property("nextSend", codecForAbsoluteTime) .property("transmitted", codecForBoolean()) .build("LastChallengeResponse"); @@ -60,8 +66,8 @@ export const codecForSessionState = (): Codec<SessionState> => buildCodecForObject<SessionState>() .property("clientId", codecForString()) .property("redirectURL", codecForStringURL()) - .property("completedURL", codecOptional(codecForStringURL())) .property("state", codecForString()) + .property("completedURL", codecOptional(codecForStringURL())) .property("lastStatus", codecOptional(codecForChallengeStatus())) .property("lastTry", codecOptional(codecForLastChallengeResponse())) .build("SessionState"); @@ -121,7 +127,7 @@ export function useSessionState(): SessionStateHandler { }); return; } - // current status + // current status, FIXME: better check to know if the state changed const ls = state.lastStatus; if ( ls.changes_left !== st.changes_left || diff --git a/packages/challenger-ui/src/pages/AnswerChallenge.tsx b/packages/challenger-ui/src/pages/AnswerChallenge.tsx index 73a79c51f..2740e1bdb 100644 --- a/packages/challenger-ui/src/pages/AnswerChallenge.tsx +++ b/packages/challenger-ui/src/pages/AnswerChallenge.tsx @@ -14,8 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { + AbsoluteTime, ChallengerApi, HttpStatusCode, + TalerProtocolTimestamp, assertUnreachable, } from "@gnu-taler/taler-util"; import { @@ -41,14 +43,18 @@ type Props = { routeAsk: RouteDefinition<{ nonce: string }>; }; -export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): VNode { +export function AnswerChallenge({ + focus, + nonce, + onComplete, + routeAsk, +}: Props): VNode { const { lib } = useChallengerApiContext(); const { i18n } = useTranslationContext(); const { state, accepted, completed } = useSessionState(); const [notification, withErrorHandler] = useLocalNotificationHandler(); const [pin, setPin] = useState<string | undefined>(); - const [lastTryError, setLastTryError] = - useState<ChallengerApi.InvalidPinResponse>(); + const errors = undefinedIfEmpty({ pin: !pin ? i18n.str`Can't be empty` : undefined, }); @@ -62,7 +68,9 @@ export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): : state.lastStatus.last_address["email"]; const onSendAgain = - !state || lastEmail === undefined + lastEmail === undefined || + state?.lastStatus == undefined || + state?.lastStatus.changes_left === 0 ? undefined : withErrorHandler( async () => { @@ -70,12 +78,16 @@ export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): return await lib.challenger.challenge(nonce, { email: lastEmail }); }, (ok) => { - if ("redirectURL" in ok.body) { - completed(ok.body.redirectURL); + if (ok.body.type === "completed") { + completed(new URL(ok.body.redirect_url)); } else { accepted({ - attemptsLeft: ok.body.attempts_left, - nextSend: ok.body.next_tx_time, + changeTargetLeft: ok.body.attempts_left, + checkPinLeft: state.lastStatus?.auth_attempts_left ?? 0, + sendCodeLeft: state.lastStatus?.pin_transmissions_left ?? 0, + nextSend: AbsoluteTime.fromProtocolTimestamp( + ok.body.retransmission_time, + ), transmitted: ok.body.transmitted, }); } @@ -84,46 +96,60 @@ export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): (fail) => { switch (fail.case) { case HttpStatusCode.BadRequest: - return i18n.str``; + return i18n.str`The request was not accepted, try reloading the app.`; case HttpStatusCode.NotFound: - return i18n.str``; + return i18n.str`Challenge not found.`; case HttpStatusCode.NotAcceptable: - return i18n.str``; + return i18n.str`Server templates are missing due to misconfiguration.`; case HttpStatusCode.TooManyRequests: - return i18n.str``; + return i18n.str`There have been too many attempts to request challenge transmissions.`; case HttpStatusCode.InternalServerError: - return i18n.str``; + return i18n.str`Server is not able to respond due to internal problems.`; } }, ); const onCheck = - errors !== undefined || (lastTryError && lastTryError.exhausted) + errors !== undefined || + state?.lastStatus == undefined || + state?.lastStatus.auth_attempts_left === 0 ? undefined : withErrorHandler( async () => { return lib.challenger.solve(nonce, { pin: pin! }); }, (ok) => { - completed(ok.body.redirectURL as URL); + if (ok.body.type === "completed") { + completed(new URL(ok.body.redirect_url)); + } else { + accepted({ + changeTargetLeft: ok.body.addresses_left, + checkPinLeft: ok.body.auth_attempts_left, + sendCodeLeft: ok.body.pin_transmissions_left, + nextSend: AbsoluteTime.fromProtocolTimestamp( + state?.lastStatus?.retransmission_time ?? + TalerProtocolTimestamp.now(), + ), + transmitted: state?.lastTry?.transmitted ?? false, + }); + } onComplete(); }, (fail) => { switch (fail.case) { case HttpStatusCode.BadRequest: - return i18n.str`Invalid request`; + return i18n.str`The request was not accepted, try reloading the app.`; case HttpStatusCode.Forbidden: { - setLastTryError(fail.body); - return i18n.str`Invalid pin`; + return i18n.str`Invalid pin.`; } case HttpStatusCode.NotFound: - return i18n.str``; + return i18n.str`Challenge not found.`; case HttpStatusCode.NotAcceptable: - return i18n.str``; + return i18n.str`Server templates are missing due to misconfiguration.`; case HttpStatusCode.TooManyRequests: - return i18n.str``; + return i18n.str`There have been too many attempts to request challenge transmissions.`; case HttpStatusCode.InternalServerError: - return i18n.str``; + return i18n.str`Server is not able to respond due to internal problems.`; default: assertUnreachable(fail); } @@ -164,11 +190,11 @@ export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): </Attention> )} </p> - {!lastTryError ? undefined : ( + {!state.lastStatus ? undefined : ( <p class="mt-2 text-lg leading-8 text-gray-600"> <i18n.Translate> You can try another PIN but just{" "} - {lastTryError.auth_attempts_left} times more. + {state.lastStatus.auth_attempts_left} times more. </i18n.Translate> </p> )} @@ -212,8 +238,21 @@ export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): <p class="mt-3 text-sm leading-6 text-gray-400"> <i18n.Translate> - You have {state.lastTry.attemptsLeft} attempts left. + We send the code {state.lastTry.checkPinLeft} more times. </i18n.Translate> + {state.lastTry.checkPinLeft < 1 ? ( + <i18n.Translate> + You can't check the PIN anymore. + </i18n.Translate> + ) : state.lastTry.checkPinLeft === 1 ? ( + <i18n.Translate> + You can check the PIN one last time. + </i18n.Translate> + ) : ( + <i18n.Translate> + You can check the PIN {state.lastTry.checkPinLeft} more times. + </i18n.Translate> + )} </p> </div> @@ -230,12 +269,31 @@ export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): <div class="mt-10 flex justify-between"> <div> <a + data-disabled={!state.lastStatus || state.lastStatus.changes_left < 1} href={routeAsk.url({ nonce })} - class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + class="relative data-[disabled=true]:bg-gray-300 data-[disabled=true]:text-white data-[disabled=true]:cursor-default inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" > <i18n.Translate>Change email</i18n.Translate> </a> - </div> + {state.lastStatus === undefined ? undefined : + <p class="mt-2 text-sm leading-6 text-gray-400"> + {state.lastStatus.changes_left < 1 ? ( + <i18n.Translate> + You can't change the email anymore. + </i18n.Translate> + ) : state.lastStatus.changes_left === 1 ? ( + <i18n.Translate> + You can change the email one last time. + </i18n.Translate> + ) : ( + <i18n.Translate> + You can change the email {state.lastStatus.changes_left}{" "} + more times. + </i18n.Translate> + )} + </p> + } + </div> <div> <Button type="submit" @@ -245,6 +303,22 @@ export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): > <i18n.Translate>Send code again</i18n.Translate> </Button> + <p class="mt-2 text-sm leading-6 text-gray-400"> + {state.lastTry.sendCodeLeft < 1 ? ( + <i18n.Translate> + We can't send you the code anymore. + </i18n.Translate> + ) : state.lastTry.sendCodeLeft === 1 ? ( + <i18n.Translate> + We can send the code one last time. + </i18n.Translate> + ) : ( + <i18n.Translate> + We can send the code {state.lastTry.sendCodeLeft} more + times. + </i18n.Translate> + )} + </p> </div> </div> </form> diff --git a/packages/challenger-ui/src/pages/AskChallenge.tsx b/packages/challenger-ui/src/pages/AskChallenge.tsx index 30b50d707..dc60562b7 100644 --- a/packages/challenger-ui/src/pages/AskChallenge.tsx +++ b/packages/challenger-ui/src/pages/AskChallenge.tsx @@ -13,7 +13,7 @@ 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 { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util"; import { Attention, Button, @@ -48,13 +48,15 @@ export function AskChallenge({ focus, }: Props): VNode { const { state, accepted, completed } = useSessionState(); + const { lib, config } = useChallengerApiContext(); + const status = state?.lastStatus; const prevEmail = !status || !status.last_address ? undefined : status.last_address["email"]; - const regexEmail = - !status || !status.restrictions ? undefined : status.restrictions["email"]; + const regexEmail = !config.restrictions + ? undefined + : config.restrictions["email"]; - const { lib } = useChallengerApiContext(); const { i18n } = useTranslationContext(); const [notification, withErrorHandler] = useLocalNotificationHandler(); const [email, setEmail] = useState<string | undefined>(); @@ -87,12 +89,16 @@ export function AskChallenge({ return lib.challenger.challenge(nonce, { email: email! }); }, (ok) => { - if ("redirectURL" in ok.body) { - completed(ok.body.redirectURL); + if (ok.body.type === "completed") { + completed(new URL(ok.body.redirect_url)); } else { accepted({ - attemptsLeft: ok.body.attempts_left, - nextSend: ok.body.next_tx_time, + changeTargetLeft: ok.body.attempts_left, + checkPinLeft: state?.lastStatus?.auth_attempts_left ?? 0, + sendCodeLeft: state?.lastStatus?.pin_transmissions_left ?? 0, + nextSend: AbsoluteTime.fromProtocolTimestamp( + ok.body.retransmission_time, + ), transmitted: ok.body.transmitted, }); } @@ -101,15 +107,15 @@ export function AskChallenge({ (fail) => { switch (fail.case) { case HttpStatusCode.BadRequest: - return i18n.str``; + return i18n.str`The request was not accepted, try reloading the app.`; case HttpStatusCode.NotFound: - return i18n.str``; + return i18n.str`Challenge not found.`; case HttpStatusCode.NotAcceptable: - return i18n.str``; + return i18n.str`Server templates are missing due to misconfiguration.`; case HttpStatusCode.TooManyRequests: - return i18n.str``; + return i18n.str`There have been too many attempts to request challenge transmissions.`; case HttpStatusCode.InternalServerError: - return i18n.str``; + return i18n.str`Server is not able to respond due to internal problems.`; } }, ); @@ -120,7 +126,7 @@ export function AskChallenge({ return ( <Fragment> - <LocalNotificationBanner notification={notification} /> + <LocalNotificationBanner notification={notification} showDebug={true} /> <div class="isolate bg-white px-6 py-12"> <div class="mx-auto max-w-2xl text-center"> @@ -211,16 +217,22 @@ export function AskChallenge({ </div> )} - {!status.changes_left ? ( - <p class="mt-3 text-sm leading-6 text-gray-400"> - <i18n.Translate>No more changes left</i18n.Translate> - </p> - ) : ( - <p class="mt-3 text-sm leading-6 text-gray-400"> - <i18n.Translate> - You can change your email address another{" "} - {status.changes_left} times. - </i18n.Translate> + {state.lastStatus === undefined ? undefined : ( + <p class="mt-2 text-sm leading-6 text-gray-400"> + {state.lastStatus.changes_left < 1 ? ( + <i18n.Translate> + You can't change the email anymore. + </i18n.Translate> + ) : state.lastStatus.changes_left === 1 ? ( + <i18n.Translate> + You can change the email one last time. + </i18n.Translate> + ) : ( + <i18n.Translate> + You can change the email {state.lastStatus.changes_left}{" "} + more times. + </i18n.Translate> + )} </p> )} </div> diff --git a/packages/challenger-ui/src/pages/Frame.tsx b/packages/challenger-ui/src/pages/Frame.tsx index 612eced0b..dd2a13d8c 100644 --- a/packages/challenger-ui/src/pages/Frame.tsx +++ b/packages/challenger-ui/src/pages/Frame.tsx @@ -14,56 +14,121 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { + Footer, + Header, + ToastBanner, + notifyError, + notifyException, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; +import { useSettingsContext } from "../context/settings.js"; +import { useEffect, useErrorBoundary } from "preact/hooks"; +import { TranslatedString } from "@gnu-taler/taler-util"; +import { + getAllBooleanPreferences, + getLabelForPreferences, + usePreferences, +} from "../context/preferences.js"; + +const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; +const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; export function Frame({ children }: { children: ComponentChildren }): VNode { + const settings = useSettingsContext(); + const [preferences, updatePreferences] = usePreferences(); + + const [error, resetError] = useErrorBoundary(); + const { i18n } = useTranslationContext(); + useEffect(() => { + if (error) { + if (error instanceof Error) { + console.log("Internal error, please report", error); + notifyException(i18n.str`Internal error, please report.`, error); + } else { + console.log("Internal error, please report", error); + notifyError( + i18n.str`Internal error, please report.`, + String(error) as TranslatedString, + ); + } + resetError(); + } + }, [error]); + return ( - <Fragment> - <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400"> - <div class="flex flex-row h-16 items-center "> - <div class="flex px-2 justify-start"> - <div class="flex-shrink-0 bg-white rounded-lg"> - <a href="#"> - <img - class="h-8 w-auto" - src='data:image/svg+xml,<?xml version="1.0" encoding="UTF-8" standalone="no"?>%0A<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">%0A <g fill="%230042b3" fill-rule="evenodd" stroke-width=".3">%0A <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />%0A <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />%0A <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />%0A </g>%0A <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />%0A</svg>' - alt="GNU Taler" - style="height: 1.5rem; margin: 0.5rem;" - /> - </a> - </div> - <span class="flex items-center text-white text-lg font-bold ml-4"> - Challenger - </span> + <div + class="min-h-full flex flex-col m-0 bg-slate-200" + style="min-height: 100vh;" + > + <Header + title="Challenger" + onLogout={undefined} + iconLinkURL="#" + sites={preferences.showChallangeSetup ? [ + ["New challenge","#/setup/1"] + ] :[]} + supportedLangs={["en"]} + > + <li> + <div class="text-xs font-semibold leading-6 text-gray-400"> + <i18n.Translate>Preferences</i18n.Translate> </div> - <div class="block flex-1 ml-6 "></div> - <div class="flex justify-end"></div> + <ul role="list" class="space-y-4"> + {getAllBooleanPreferences().map((set) => { + const isOn: boolean = !!preferences[set]; + return ( + <li key={set} class="pl-2"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + {getLabelForPreferences(set, i18n)} + </span> + </span> + <button + type="button" + name={`${set} switch`} + data-enabled={isOn} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + updatePreferences(set, !isOn); + }} + > + <span + aria-hidden="true" + data-enabled={isOn} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </li> + ); + })} + </ul> + </li> + </Header> + + <div class="fixed z-20 top-14 w-full"> + <div class="mx-auto w-4/5"> + <ToastBanner /> </div> - </header> + </div> <main class="flex-1">{children}</main> - - <footer class="bottom-4 mb-4"> - <div class="mt-8 mx-8 md:order-1 md:mt-0"> - <div> - <p class="text-xs leading-5 text-gray-400"> - Learn more about{" "} - <a - target="_blank" - rel="noreferrer noopener" - class="font-semibold text-gray-500 hover:text-gray-400" - href="https://taler.net" - > - GNU Taler - </a> - </p> - </div> - <div style="flex-grow: 1;"></div> - <p class="text-xs leading-5 text-gray-400"> - Copyright © 2014—2023 Taler Systems SA.{" "} - </p> - </div> - </footer> - </Fragment> + + <Footer + testingUrlKey="challenger-base-url" + GIT_HASH={GIT_HASH} + VERSION={VERSION} + /> + </div> ); } diff --git a/packages/idb-bridge/package.json b/packages/idb-bridge/package.json index ce3123619..a176fadc9 100644 --- a/packages/idb-bridge/package.json +++ b/packages/idb-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/idb-bridge", - "version": "0.11.4", + "version": "0.12.2", "description": "IndexedDB implementation that uses SQLite3 as storage", "main": "./dist/idb-bridge.js", "module": "./lib/index.js", diff --git a/packages/idb-bridge/src/util/errors.ts b/packages/idb-bridge/src/util/errors.ts index 6c8f81811..57fa46f96 100644 --- a/packages/idb-bridge/src/util/errors.ts +++ b/packages/idb-bridge/src/util/errors.ts @@ -41,7 +41,7 @@ const messages = { export class AbortError extends Error { constructor(message = messages.AbortError) { super(); - Object.setPrototypeOf(this, ConstraintError.prototype); + Object.setPrototypeOf(this, AbortError.prototype); this.name = "AbortError"; this.message = message; } diff --git a/packages/merchant-backend-ui/package.json b/packages/merchant-backend-ui/package.json index bc8627312..683329245 100644 --- a/packages/merchant-backend-ui/package.json +++ b/packages/merchant-backend-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/merchant-backend-ui", - "version": "0.11.4", + "version": "0.12.2", "license": "AGPL-3.0-or-later", "scripts": { "compile": "tsc && ./build.mjs", diff --git a/packages/merchant-backoffice-ui/package.json b/packages/merchant-backoffice-ui/package.json index 8aabdce87..babadbb6a 100644 --- a/packages/merchant-backoffice-ui/package.json +++ b/packages/merchant-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/merchant-backoffice-ui", - "version": "0.11.4", + "version": "0.12.2", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx index 665137415..ae9078678 100644 --- a/packages/merchant-backoffice-ui/src/Routing.tsx +++ b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -60,6 +60,9 @@ import TemplateQrPage from "./paths/instance/templates/qr/index.js"; import TemplateUpdatePage from "./paths/instance/templates/update/index.js"; import TemplateUsePage from "./paths/instance/templates/use/index.js"; import TokenPage from "./paths/instance/token/index.js"; +import TokenFamilyCreatePage from "./paths/instance/tokenfamilies/create/index.js"; +import TokenFamilyListPage from "./paths/instance/tokenfamilies/list/index.js"; +import TokenFamilyUpdatePage from "./paths/instance/tokenfamilies/update/index.js"; import TransferCreatePage from "./paths/instance/transfers/create/index.js"; import TransferListPage from "./paths/instance/transfers/list/index.js"; import InstanceUpdatePage, { @@ -91,10 +94,6 @@ export enum InstancePaths { order_new = "/order/new", order_details = "/order/:oid/details", - reserves_list = "/reserves", - reserves_details = "/reserves/:rid/details", - reserves_new = "/reserves/new", - kyc = "/kyc", transfers_list = "/transfers", @@ -106,6 +105,10 @@ export enum InstancePaths { templates_use = "/templates/:tid/use", templates_qr = "/templates/:tid/qr", + token_family_list = "/tokenfamilies", + token_family_update = "/tokenfamilies/:slug/update", + token_family_new = "/tokenfamilies/new", + webhooks_list = "/webhooks", webhooks_update = "/webhooks/:tid/update", webhooks_new = "/webhooks/new", @@ -139,7 +142,6 @@ export const publicPages = { const history = createHashHistory(); export function Routing(_p: Props): VNode { - // const { i18n } = useTranslationContext(); const { state } = useSessionContext(); type GlobalNotifState = @@ -163,81 +165,10 @@ export function Routing(_p: Props): VNode { accounts !== undefined && accounts.accounts.length < 1 && (AbsoluteTime.isNever(preference.hideMissingAccountUntil) || - AbsoluteTime.cmp(now, preference.hideMissingAccountUntil) > 1); + AbsoluteTime.cmp(now, preference.hideMissingAccountUntil) > 0); const shouldLogin = state.status === "loggedOut"; - // function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) { - // return function ServerErrorRedirectToImpl( - // error: HttpError<TalerErrorDetail>, - // ) { - // if (error.type === ErrorType.TIMEOUT) { - // setGlobalNotification({ - // 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}"`, - // type: "ERROR", - // to, - // }); - // } else { - // setGlobalNotification({ - // message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, - // description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, - // details: - // error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER - // ? error.payload.hint - // : undefined, - // type: "ERROR", - // to, - // }); - // } - // return <Redirect to={to} />; - // }; - // } - - // const LoginPageAccessDeniend = onUnauthorized - // const LoginPageAccessDenied = () => { - // return ( - // <Fragment> - // <NotificationCard - // notification={{ - // message: i18n.str`Access denied`, - // description: i18n.str`Session expired or password changed.`, - // type: "ERROR", - // }} - // /> - // <LoginPage /> - // </Fragment> - // ); - // }; - - // function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<unknown>) { - // return function IfAdminCreateDefaultOrImpl(props?: T) { - // if (state.isAdmin && state.instance === DEFAULT_ADMIN_USERNAME) { - // return ( - // <Fragment> - // <NotificationCard - // notification={{ - // message: i18n.str`No 'default' instance configured yet.`, - // description: i18n.str`Create a 'default' instance to begin using the merchant backoffice.`, - // type: "INFO", - // }} - // /> - // <InstanceCreatePage - // forceId={DEFAULT_ADMIN_USERNAME} - // onConfirm={() => { - // route(InstancePaths.bank_list); - // }} - // /> - // </Fragment> - // ); - // } - // if (props) { - // return <Next {...props} />; - // } - // return <Next />; - // }; - // } - if (shouldLogin) { return ( <Fragment> @@ -251,12 +182,24 @@ export function Routing(_p: Props): VNode { return ( <Fragment> <Menu /> - <BankAccountBanner /> - <BankAccountCreatePage - onConfirm={() => { - route(InstancePaths.bank_list); - }} - /> + <Router history={history}> + <Route path={InstancePaths.interface} component={Settings} /> + <Route + default + component={() => { + return ( + <Fragment> + <BankAccountBanner /> + <BankAccountCreatePage + onConfirm={() => { + route(InstancePaths.bank_list); + }} + /> + </Fragment> + ); + }} + /> + </Router> </Fragment> ); } @@ -267,21 +210,24 @@ export function Routing(_p: Props): VNode { <KycBanner /> <NotificationCard notification={globalNotification} /> {error && ( - <NotificationCard - notification={{ - message: "Internal error, please repot", - type: "ERROR", - description: ( - <pre> - { - (error instanceof Error - ? error.stack - : String(error)) as TranslatedString - } - </pre> - ), - }} - /> + <Fragment> + <NotificationCard + notification={{ + message: "Internal error, please report", + type: "ERROR", + + description: ( + <pre> + { + (error instanceof Error + ? error.stack + : String(error)) as TranslatedString + } + </pre> + ), + }} + /> + </Fragment> )} <Router @@ -472,6 +418,39 @@ export function Routing(_p: Props): VNode { route(InstancePaths.transfers_list); }} /> + {/* * + * Token family pages + */} + <Route + path={InstancePaths.token_family_list} + component={TokenFamilyListPage} + onCreate={() => { + route(InstancePaths.token_family_new); + }} + onSelect={(slug: string) => { + route(InstancePaths.token_family_update.replace(":slug", slug)); + }} + /> + <Route + path={InstancePaths.token_family_update} + component={TokenFamilyUpdatePage} + onConfirm={() => { + route(InstancePaths.token_family_list); + }} + onBack={() => { + route(InstancePaths.token_family_list); + }} + /> + <Route + path={InstancePaths.token_family_new} + component={TokenFamilyCreatePage} + onConfirm={() => { + route(InstancePaths.token_family_list); + }} + onBack={() => { + route(InstancePaths.token_family_list); + }} + /> {/** * Webhooks pages */} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx index ad3cb0e32..080b9508e 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx @@ -52,14 +52,18 @@ export function InputDuration<T>({ const { error, required, value: anyValue, onChange } = useField<T>(name); let strValue = ""; - const value: Duration = anyValue + const value: Duration = anyValue; if (!value) { strValue = ""; } else if (value.d_ms === "forever") { strValue = i18n.str`forever`; } else { if (value.d_ms === undefined) { - throw Error(`assertion error: duration should have a d_ms but got '${JSON.stringify(value)}'`) + throw Error( + `assertion error: duration should have a d_ms but got '${JSON.stringify( + value, + )}'`, + ); } strValue = formatDuration( intervalToDuration({ start: 0, end: value.d_ms }), @@ -96,7 +100,7 @@ export function InputDuration<T>({ return ( <div class="field is-horizontal"> - <div class="field-label is-normal is-flex-grow-3"> + <div class="field-label is-normal"> <label class="label"> {label} {tooltip && ( @@ -107,69 +111,65 @@ export function InputDuration<T>({ </label> </div> - <div class="is-flex-grow-3"> - <div class="field-body "> - <div class="field"> - <div class="field has-addons"> - <p class={expand ? "control is-expanded " : "control "}> - <input - class="input" - type="text" - readonly - value={strValue} - placeholder={placeholder} - onClick={() => { - if (!readonly) setOpened(true); - }} - /> - {required && ( - <span class="icon has-text-danger is-right"> - <i class="mdi mdi-alert" /> - </span> - )} - </p> - <div - class="control" + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + <p class={expand ? "control is-expanded " : "control "}> + <input + class="input" + type="text" + readonly + value={strValue} + placeholder={placeholder} onClick={() => { if (!readonly) setOpened(true); }} - > - <a class="button is-static"> - <span class="icon"> - <i class="mdi mdi-clock" /> - </span> - </a> - </div> + /> + {required && ( + <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> + )} + </p> + <div + class="control" + onClick={() => { + if (!readonly) setOpened(true); + }} + > + <a class="button is-static"> + <span class="icon"> + <i class="mdi mdi-clock" /> + </span> + </a> </div> - {error && <p class="help is-danger">{error}</p>} </div> - {withForever && ( - <span data-tooltip={i18n.str`change value to never`}> - <button - class="button is-info mr-3" - onClick={() => onChange({ d_ms: "forever" } as any)} - > - <i18n.Translate>forever</i18n.Translate> - </button> - </span> - )} - {!readonly && !withoutClear && ( - <span data-tooltip={i18n.str`change value to empty`}> - <button - class="button is-info " - onClick={() => onChange(undefined as any)} - > - <i18n.Translate>clear</i18n.Translate> - </button> - </span> - )} - {side} + {error && <p class="help is-danger">{error}</p>} + <span class="has-text-grey">{help}</span> </div> - <span> - {help} - </span> - </div> + {withForever && ( + <span data-tooltip={i18n.str`change value to never`}> + <button + class="button is-info mr-3" + onClick={() => onChange({ d_ms: "forever" } as any)} + > + <i18n.Translate>forever</i18n.Translate> + </button> + </span> + )} + {!readonly && !withoutClear && ( + <span data-tooltip={i18n.str`change value to empty`}> + <button + class="button is-info " + onClick={() => onChange(undefined as any)} + > + <i18n.Translate>clear</i18n.Translate> + </button> + </span> + )} + {side} + </div> {opened && ( <SimpleModal onCancel={() => setOpened(false)}> diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx index 4ac798afe..585894863 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx @@ -222,14 +222,15 @@ export function InputPaytoForm<T>({ if (nv !== undefined && nv.isKnown) { if (nv.targetType === "iban" && paths.length >= 2) { //FIXME: workaround EBIC not supported - paths[0] = paths[1] + paths[0] = paths[1]; + delete paths[1]; } setValue({ target: nv.targetType, params: nv.params, path1: paths.length >= 1 ? paths[0] : undefined, path2: paths.length >= 2 ? paths[1] : undefined, - }); + }); } }, [initialValueStr]); @@ -267,17 +268,20 @@ export function InputPaytoForm<T>({ const path1WithSlash = value.path1 && !value.path1.endsWith("/") ? value.path1 + "/" : value.path1; - const str = + const pto = hasErrors || !value.target ? undefined - : stringifyPaytoUri({ + : { targetType: value.target, targetPath: value.path2 ? `${path1WithSlash}${value.path2}` : value.path1 ?? "", params: value.params ?? ({} as any), - isKnown: false, - }); + isKnown: false as const, + }; + + const str = !pto ? undefined : stringifyPaytoUri(pto); + useEffect(() => { onChange(str as any); }, [str]); @@ -292,7 +296,7 @@ export function InputPaytoForm<T>({ > <InputSelector<Entity> name="target" - label={i18n.str`Account type`} + label={i18n.str`Type`} tooltip={i18n.str`Method to use for wire transfer`} values={targets} readonly={readonly} @@ -426,7 +430,9 @@ export function InputPaytoForm<T>({ name="params.receiver-name" readonly={readonly} label={i18n.str`Owner's name`} + placeholder="John Doe" tooltip={i18n.str`Legal name of the person holding the account.`} + help={i18n.str`It should match the bank account name.`} /> </Fragment> )} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx b/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx index 8c935f33b..89a4dc48c 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx @@ -18,7 +18,7 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode } from "preact"; +import { ComponentChildren, h, VNode } from "preact"; import { InputProps, useField } from "./useField.js"; interface Props<T> extends InputProps<T> { @@ -26,11 +26,12 @@ interface Props<T> extends InputProps<T> { readonly?: boolean; expand?: boolean; threeState?: boolean; + side?: ComponentChildren; toBoolean?: (v?: any) => boolean | undefined; fromBoolean?: (s: boolean | undefined) => any; } -const defaultToBoolean = (f?: any): boolean | undefined => f || ""; +const defaultToBoolean = (f?: any): boolean | undefined => f; const defaultFromBoolean = (v: boolean | undefined): any => v as any; export function InputToggle<T>({ @@ -41,6 +42,7 @@ export function InputToggle<T>({ label, help, threeState, + side, expand, fromBoolean = defaultFromBoolean, toBoolean = defaultToBoolean, @@ -56,7 +58,7 @@ export function InputToggle<T>({ return ( <div class="field is-horizontal"> <div class="field-label is-normal"> - <label class="label" > + <label class="label"> {label} {tooltip && ( <span class="icon has-tooltip-right" data-tooltip={tooltip}> @@ -68,23 +70,37 @@ export function InputToggle<T>({ <div class="field-body is-flex-grow-3"> <div class="field"> <p class={expand ? "control is-expanded" : "control"}> + {/* {String(toBoolean(value))} */} <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}> <input type="checkbox" - class={toBoolean(value) === undefined ? "is-indeterminate" : "toggle-checkbox"} + class={"toggle-checkbox"} checked={toBoolean(value)} placeholder={placeholder} + ref={(d) => { + if (d) { + d.indeterminate = + !!threeState && toBoolean(value) === undefined; + } + }} readonly={readonly} name={String(name)} disabled={readonly} onChange={onCheckboxClick} /> - <div class={`toggle-switch ${readonly ? "disabled" : ""}`} style={{ cursor: readonly ? "default" : undefined }}></div> + + <div + class={`toggle-switch ${readonly ? "disabled" : ""} ${ + toBoolean(value) === undefined ? "no-dot" : "" + }`} + style={{ cursor: readonly ? "default" : undefined }} + ></div> </label> - {help} + <p>{help}</p> </p> {error && <p class="help is-danger">{error}</p>} </div> + {side} </div> </div> ); diff --git a/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx b/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx index b8cd4c2d2..04bcbc2be 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx @@ -69,6 +69,7 @@ export function InputWithAddon<T>({ )} </label> </div> + <div class="field-body is-flex-grow-3"> <div class="field"> <div class="field has-addons"> diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx index efcca302f..ae1bb27d1 100644 --- a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx +++ b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -28,16 +28,16 @@ import { InputDuration } from "../form/InputDuration.js"; import { InputGroup } from "../form/InputGroup.js"; import { InputImage } from "../form/InputImage.js"; import { InputLocation } from "../form/InputLocation.js"; -import { InputSelector } from "../form/InputSelector.js"; import { InputToggle } from "../form/InputToggle.js"; import { InputWithAddon } from "../form/InputWithAddon.js"; -import { TextField } from "../form/TextField.js"; export function DefaultInstanceFormFields({ readonlyId, showId, + showLessFields, }: { readonlyId?: boolean; + showLessFields?: boolean; showId: boolean; }): VNode { const { i18n } = useTranslationContext(); @@ -60,59 +60,63 @@ export function DefaultInstanceFormFields({ tooltip={i18n.str`Legal name of the business represented by this instance.`} /> - <Input<Entity> - name="email" - label={i18n.str`Email`} - tooltip={i18n.str`Contact email`} - /> + {showLessFields ? undefined : ( + <Fragment> + <Input<Entity> + name="email" + label={i18n.str`Email`} + tooltip={i18n.str`Contact email`} + /> - <Input<Entity> - name="website" - label={i18n.str`Website URL`} - tooltip={i18n.str`URL.`} - /> + <Input<Entity> + name="website" + label={i18n.str`Website URL`} + tooltip={i18n.str`URL.`} + /> - <InputImage<Entity> - name="logo" - label={i18n.str`Logo`} - tooltip={i18n.str`Logo image.`} - /> + <InputImage<Entity> + name="logo" + label={i18n.str`Logo`} + tooltip={i18n.str`Logo image.`} + /> - <InputGroup - name="address" - label={i18n.str`Address`} - tooltip={i18n.str`Physical location of the merchant.`} - > - <InputLocation name="address" /> - </InputGroup> + <InputGroup + name="address" + label={i18n.str`Address`} + tooltip={i18n.str`Physical location of the merchant.`} + > + <InputLocation name="address" /> + </InputGroup> - <InputGroup - name="jurisdiction" - label={i18n.str`Jurisdiction`} - tooltip={i18n.str`Jurisdiction for legal disputes with the merchant.`} - > - <InputLocation name="jurisdiction" /> - </InputGroup> + <InputGroup + name="jurisdiction" + label={i18n.str`Jurisdiction`} + tooltip={i18n.str`Jurisdiction for legal disputes with the merchant.`} + > + <InputLocation name="jurisdiction" /> + </InputGroup> - <InputToggle<Entity> - name="use_stefan" - label={i18n.str`Pay transaction fee`} - tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`} - /> + <InputToggle<Entity> + name="use_stefan" + label={i18n.str`Pay transaction fee`} + tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`} + /> - <InputDuration<Entity> - name="default_pay_delay" - label={i18n.str`Default payment delay`} - withForever - tooltip={i18n.str`Time customers have to pay an order before the offer expires by default.`} - /> + <InputDuration<Entity> + name="default_pay_delay" + label={i18n.str`Default payment delay`} + withForever + tooltip={i18n.str`Time customers have to pay an order before the offer expires by default.`} + /> - <InputDuration<Entity> - name="default_wire_transfer_delay" - label={i18n.str`Default wire transfer delay`} - tooltip={i18n.str`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`} - withForever - /> + <InputDuration<Entity> + name="default_wire_transfer_delay" + label={i18n.str`Default wire transfer delay`} + tooltip={i18n.str`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`} + withForever + /> + </Fragment> + )} </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx index 4a1f6a9df..aeb49e81d 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -118,6 +118,16 @@ export function Sidebar({ mobile }: Props): VNode { </span> </a> </li> + <li> + <a href={"/tokenfamilies"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-key" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Token Families</i18n.Translate> + </span> + </a> + </li> {needKYC && ( <li> <a href={"/kyc"} class="has-icon"> diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx index a35c07ace..c13839d2d 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/index.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/index.tsx @@ -28,6 +28,12 @@ function getInstanceTitle(path: string, id: string): string { switch (path) { case InstancePaths.settings: return `${id}: Settings`; + case InstancePaths.bank_new: + return `${id}: New bank account`; + case InstancePaths.bank_list: + return `${id}: Bank accounts`; + case InstancePaths.bank_update: + return `${id}: Update bank Account`; case InstancePaths.order_list: return `${id}: Orders`; case InstancePaths.order_new: @@ -38,10 +44,6 @@ function getInstanceTitle(path: string, id: string): string { return `${id}: New product`; case InstancePaths.inventory_update: return `${id}: Update product`; - case InstancePaths.reserves_new: - return `${id}: New reserve`; - case InstancePaths.reserves_list: - return `${id}: Reserves`; case InstancePaths.transfers_list: return `${id}: Transfers`; case InstancePaths.transfers_new: @@ -53,11 +55,11 @@ function getInstanceTitle(path: string, id: string): string { case InstancePaths.webhooks_update: return `${id}: Update webhook`; case InstancePaths.otp_devices_list: - return `${id}: otp devices`; + return `${id}: OTP devices`; case InstancePaths.otp_devices_new: - return `${id}: New otp devices`; + return `${id}: New OTP device`; case InstancePaths.otp_devices_update: - return `${id}: Update otp devices`; + return `${id}: Update OTP device`; case InstancePaths.templates_new: return `${id}: New template`; case InstancePaths.templates_update: @@ -68,6 +70,12 @@ function getInstanceTitle(path: string, id: string): string { return `${id}: Use template`; case InstancePaths.interface: return `${id}: Interface`; + case InstancePaths.token_family_list: + return `${id}: Token families`; + case InstancePaths.token_family_new: + return `${id}: New token family`; + case InstancePaths.token_family_update: + return `${id}: Update token family`; default: return ""; } diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx index 43062d13e..ba32950b5 100644 --- a/packages/merchant-backoffice-ui/src/components/modal/index.tsx +++ b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -31,6 +31,8 @@ import { AccountLetter, codecForAccountLetter, PaytoString, + PaytoUri, + stringifyPaytoUri, } from "@gnu-taler/taler-util"; interface Props { @@ -288,6 +290,131 @@ export function ImportingAccountModal({ ); } +interface CompareAccountsModalProps { + onCancel: () => void; + onConfirm: (account: PaytoString) => void; + formPayto: PaytoUri | undefined; + testPayto: PaytoUri; +} + +function getHostFromHostPath(s: string | undefined) { + if (!s) return undefined; + try { + const u = new URL(`https://${s}`); + const endpath = u.pathname.lastIndexOf("/"); + return u.origin + u.pathname.substring(0, endpath); + } catch (e) { + return undefined; + } +} + +function getAccountIdFromHostPath(s: string | undefined) { + if (!s) return undefined; + try { + const u = new URL(`https://${s}`); + const endpath = u.pathname.lastIndexOf("/"); + return u.pathname.substring(endpath + 1); + } catch (e) { + return undefined; + } +} + +export function CompareAccountsModal({ + onCancel, + onConfirm, + formPayto, + testPayto, +}: CompareAccountsModalProps): VNode { + const { i18n } = useTranslationContext(); + return ( + <ConfirmModal + label={i18n.str`Update form`} + description={i18n.str`Comparing account details`} + active + onCancel={onCancel} + onConfirm={() => onConfirm(stringifyPaytoUri(testPayto))} + > + <p> + <i18n.Translate> + Testing against the account info URL succeeded but the account + information reported is different with the account details form. + </i18n.Translate> + </p> + <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <td> + <i18n.Translate>Field</i18n.Translate> + </td> + <td> + <i18n.Translate>In the form</i18n.Translate> + </td> + <td> + <i18n.Translate>Reported</i18n.Translate> + </td> + </tr> + </thead> + <tbody> + <tr> + <td> + <i18n.Translate>Type</i18n.Translate> + </td> + <td>{formPayto?.targetType ?? "--"}</td> + <td>{testPayto.targetType}</td> + </tr> + {testPayto.targetType === "iban" && ( + <tr> + <td> + <i18n.Translate>IBAN</i18n.Translate> + </td> + <td>{formPayto?.targetPath ?? "--"}</td> + <td>{testPayto.targetPath}</td> + </tr> + )} + {testPayto.targetType === "bitcoin" && ( + <tr> + <td> + <i18n.Translate>Address</i18n.Translate> + </td> + <td>{formPayto?.targetPath ?? "--"}</td> + <td>{testPayto.targetPath}</td> + </tr> + )} + {testPayto.targetType === "x-taler-bank" && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Host</i18n.Translate> + </td> + <td>{getHostFromHostPath(formPayto?.targetPath) ?? "--"}</td> + <td>{getHostFromHostPath(testPayto.targetPath)}</td> + </tr> + <tr> + <td> + <i18n.Translate>Account id</i18n.Translate> + </td> + <td> + {getAccountIdFromHostPath(formPayto?.targetPath) ?? "--"} + </td> + <td>{getAccountIdFromHostPath(testPayto.targetPath)}</td> + </tr> + </Fragment> + )} + <tr> + <td> + <i18n.Translate>Owner's name</i18n.Translate> + </td> + <td>{formPayto?.params["receiver-name"] ?? "--"}</td> + <td>{testPayto.params["receiver-name"]}</td> + </tr> + </tbody> + </table> + </div> + </ConfirmModal> + ); +} + interface DeleteModalProps { element: { id: string; name: string }; onCancel: () => void; diff --git a/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx index 52ac2a1fe..3d15918dc 100644 --- a/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx @@ -21,6 +21,7 @@ import { FormErrors, FormProvider } from "../form/FormProvider.js"; import { InputNumber } from "../form/InputNumber.js"; import { InputSearchOnList } from "../form/InputSearchOnList.js"; import { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { WithId } from "../../declaration.js"; type Form = { product: TalerMerchantApi.ProductDetail & WithId; diff --git a/packages/merchant-backoffice-ui/src/components/tokenfamily/TokenFamilyForm.tsx b/packages/merchant-backoffice-ui/src/components/tokenfamily/TokenFamilyForm.tsx new file mode 100644 index 000000000..b4c49502d --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/tokenfamily/TokenFamilyForm.tsx @@ -0,0 +1,140 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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 Christian Blättler + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h } from "preact"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import * as yup from "yup"; +import { TokenFamilyCreateSchema } from "../../schemas/index.js"; +import { FormErrors, FormProvider } from "../form/FormProvider.js"; +import { Input } from "../form/Input.js"; +import { InputWithAddon } from "../form/InputWithAddon.js"; +import { InputDate } from "../form/InputDate.js"; +import { InputDuration } from "../form/InputDuration.js"; +import { InputSelector } from "../form/InputSelector.js"; +import { useSessionContext } from "../../context/session.js"; +import { TalerMerchantApi } from "@gnu-taler/taler-util"; + +type Entity = TalerMerchantApi.TokenFamilyCreateRequest; + +interface Props { + onSubscribe: (c?: () => Entity | undefined) => void; + initial?: Partial<Entity>; + alreadyExist?: boolean; +} + +export function TokenFamilyForm({ onSubscribe }: Props) { + const [value, valueHandler] = useState<Partial<Entity>>({ + slug: "", + name: "", + description: "", + description_i18n: {}, + kind: TalerMerchantApi.TokenFamilyKind.Discount, + duration: { d_us: "forever" }, + valid_after: { t_s: "never" }, + valid_before: { t_s: "never" }, + }); + let errors: FormErrors<Entity> = {}; + + try { + TokenFamilyCreateSchema.validateSync(value, { + abortEarly: false, + }); + } catch (err) { + if (err instanceof yup.ValidationError) { + const yupErrors = err.inner as yup.ValidationError[]; + errors = yupErrors.reduce( + (prev, cur) => + !cur.path ? prev : { ...prev, [cur.path]: cur.message }, + {}, + ); + } + } + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submit = useCallback((): Entity | undefined => { + // HACK: Think about how this can be done better + return value as Entity; + }, [value]); + + useEffect(() => { + onSubscribe(hasErrors ? undefined : submit); + }, [submit, hasErrors]); + + const { state } = useSessionContext(); + const { i18n } = useTranslationContext(); + + return ( + <div> + <FormProvider<Entity> + name="token_family" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <InputWithAddon<Entity> + name="slug" + addonBefore={new URL("tokenfamily/", state.backendUrl.href).href} + label={i18n.str`Slug`} + tooltip={i18n.str`token family slug to use in URLs (for internal use only)`} + /> + <InputSelector<Entity> + name="kind" + label={i18n.str`Kind`} + tooltip={i18n.str`token family kind`} + values={["discount", "subscription"]} + /> + <Input<Entity> + name="name" + inputType="text" + label={i18n.str`Name`} + tooltip={i18n.str`user-readable token family name`} + /> + <Input<Entity> + name="description" + inputType="multiline" + label={i18n.str`Description`} + tooltip={i18n.str`token family description for customers`} + /> + <InputDate<Entity> + name="valid_after" + label={i18n.str`Valid After`} + tooltip={i18n.str`token family can issue tokens after this date`} + withTimestampSupport + /> + <InputDate<Entity> + name="valid_before" + label={i18n.str`Valid Before`} + tooltip={i18n.str`token family can issue tokens until this date`} + withTimestampSupport + /> + <InputDuration<Entity> + name="duration" + label={i18n.str`Duration`} + tooltip={i18n.str`validity duration of a issued token`} + withForever + /> + </FormProvider> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/declaration.d.ts b/packages/merchant-backoffice-ui/src/declaration.d.ts index 1baf80ba6..6f6e23b42 100644 --- a/packages/merchant-backoffice-ui/src/declaration.d.ts +++ b/packages/merchant-backoffice-ui/src/declaration.d.ts @@ -22,3 +22,1707 @@ interface WithId { id: string; } + +type Amount = string; +type UUID = string; +type Integer = number; + +interface WireAccount { + // payto:// URI identifying the account and wire method + payto_uri: string; + + // URI to convert amounts from or to the currency used by + // this wire account of the exchange. Missing if no + // conversion is applicable. + conversion_url?: string; + + // Restrictions that apply to bank accounts that would send + // funds to the exchange (crediting this exchange bank account). + // Optional, empty array for unrestricted. + credit_restrictions: AccountRestriction[]; + + // Restrictions that apply to bank accounts that would receive + // funds from the exchange (debiting this exchange bank account). + // Optional, empty array for unrestricted. + debit_restrictions: AccountRestriction[]; + + // Signature using the exchange's offline key over + // a TALER_MasterWireDetailsPS + // with purpose TALER_SIGNATURE_MASTER_WIRE_DETAILS. + master_sig: EddsaSignature; +} + +type AccountRestriction = RegexAccountRestriction | DenyAllAccountRestriction; + +// Account restriction that disables this type of +// account for the indicated operation categorically. +interface DenyAllAccountRestriction { + type: "deny"; +} + +// Accounts interacting with this type of account +// restriction must have a payto://-URI matching +// the given regex. +interface RegexAccountRestriction { + type: "regex"; + + // Regular expression that the payto://-URI of the + // partner account must follow. The regular expression + // should follow posix-egrep, but without support for character + // classes, GNU extensions, back-references or intervals. See + // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html + // for a description of the posix-egrep syntax. Applications + // may support regexes with additional features, but exchanges + // must not use such regexes. + payto_regex: string; + + // Hint for a human to understand the restriction + // (that is hopefully easier to comprehend than the regex itself). + human_hint: string; + + // Map from IETF BCP 47 language tags to localized + // human hints. + human_hint_i18n?: { [lang_tag: string]: string }; +} +interface LoginToken { + token: string, + expiration: Timestamp, +} +// token used to get loginToken +// must forget after used +declare const __ac_token: unique symbol; +type AccessToken = string & { + [__ac_token]: true; +}; + +export namespace ExchangeBackend { + interface WireResponse { + // Master public key of the exchange, must match the key returned in /keys. + master_public_key: EddsaPublicKey; + + // Array of wire accounts operated by the exchange for + // incoming wire transfers. + accounts: WireAccount[]; + + // Object mapping names of wire methods (i.e. "sepa" or "x-taler-bank") + // to wire fees. + fees: { method: AggregateTransferFee }; + } + interface AggregateTransferFee { + // Per transfer wire transfer fee. + wire_fee: Amount; + + // Per transfer closing fee. + closing_fee: Amount; + + // What date (inclusive) does this fee go into effect? + // The different fees must cover the full time period in which + // any of the denomination keys are valid without overlap. + start_date: Timestamp; + + // What date (exclusive) does this fee stop going into effect? + // The different fees must cover the full time period in which + // any of the denomination keys are valid without overlap. + end_date: Timestamp; + + // Signature of TALER_MasterWireFeePS with + // purpose TALER_SIGNATURE_MASTER_WIRE_FEES. + sig: EddsaSignature; + } +} +export namespace MerchantBackend { + interface ErrorDetail { + // Numeric error code unique to the condition. + // The other arguments are specific to the error value reported here. + code: number; + + // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ... + // Should give a human-readable hint about the error's nature. Optional, may change without notice! + hint?: string; + + // Optional detail about the specific input value that failed. May change without notice! + detail?: string; + + // Name of the parameter that was bogus (if applicable). + parameter?: string; + + // Path to the argument that was bogus (if applicable). + path?: string; + + // Offset of the argument that was bogus (if applicable). + offset?: string; + + // Index of the argument that was bogus (if applicable). + index?: string; + + // Name of the object that was bogus (if applicable). + object?: string; + + // Name of the currency than was problematic (if applicable). + currency?: string; + + // Expected type (if applicable). + type_expected?: string; + + // Type that was provided instead (if applicable). + type_actual?: string; + } + + // Delivery location, loosely modeled as a subset of + // ISO20022's PostalAddress25. + interface Tax { + // the name of the tax + name: string; + + // amount paid in tax + tax: Amount; + } + + interface Auditor { + // official name + name: string; + + // Auditor's public key + auditor_pub: EddsaPublicKey; + + // Base URL of the auditor + url: string; + } + interface Exchange { + // the exchange's base URL + url: string; + + // master public key of the exchange + master_pub: EddsaPublicKey; + } + + interface Product { + // merchant-internal identifier for the product. + product_id?: string; + + // Human-readable product description. + description: string; + + // Map from IETF BCP 47 language tags to localized descriptions + description_i18n?: { [lang_tag: string]: string }; + + // The number of units of the product to deliver to the customer. + quantity: Integer; + + // The unit in which the product is measured (liters, kilograms, packages, etc.) + unit: string; + + // The price of the product; this is the total price for quantity times unit of this product. + price?: Amount; + + // An optional base64-encoded product image + image: ImageDataUrl; + + // a list of taxes paid by the merchant for this product. Can be empty. + taxes: Tax[]; + + // time indicating when this product should be delivered + delivery_date?: TalerProtocolTimestamp; + + // Minimum age buyer must have (in years). Default is 0. + minimum_age?: Integer; + } + interface Merchant { + // label for a location with the business address of the merchant + address: Location; + + // the merchant's legal name of business + name: string; + + // label for a location that denotes the jurisdiction for disputes. + // Some of the typical fields for a location (such as a street address) may be absent. + jurisdiction: Location; + } + + interface VersionResponse { + // libtool-style representation of the Merchant protocol version, see + // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning + // The format is "current:revision:age". + version: string; + + // Name of the protocol. + name: "taler-merchant"; + + // Currency supported by this backend. + currency: string; + } + interface Location { + // Nation with its own government. + country?: string; + + // Identifies a subdivision of a country such as state, region, county. + country_subdivision?: string; + + // Identifies a subdivision within a country sub-division. + district?: string; + + // Name of a built-up area, with defined boundaries, and a local government. + town?: string; + + // Specific location name within the town. + town_location?: string; + + // Identifier consisting of a group of letters and/or numbers that + // is added to a postal address to assist the sorting of mail. + post_code?: string; + + // Name of a street or thoroughfare. + street?: string; + + // Name of the building or house. + building_name?: string; + + // Number that identifies the position of a building on a street. + building_number?: string; + + // Free-form address lines, should not exceed 7 elements. + address_lines?: string[]; + } + namespace Instances { + //POST /private/instances/$INSTANCE/auth + interface InstanceAuthConfigurationMessage { + // Type of authentication. + // "external": The mechant backend does not do + // any authentication checks. Instead an API + // gateway must do the authentication. + // "token": The merchant checks an auth token. + // See "token" for details. + method: "external" | "token"; + + // For method "external", this field is mandatory. + // The token MUST begin with the string "secret-token:". + // After the auth token has been set (with method "token"), + // the value must be provided in a "Authorization: Bearer $token" + // header. + token?: string; + } + //POST /private/instances + interface InstanceConfigurationMessage { + // Name of the merchant instance to create (will become $INSTANCE). + id: string; + + // Merchant name corresponding to this instance. + name: string; + + // Type of the user (business or individual). + // Defaults to 'business'. Should become mandatory field + // in the future, left as optional for API compatibility for now. + user_type?: MerchantUserType; + + // Merchant email for customer contact. + email?: string; + + // Merchant public website. + website?: string; + + // Merchant logo. + logo?: ImageDataUrl; + + // "Authentication" header required to authorize management access the instance. + // Optional, if not given authentication will be disabled for + // this instance (hopefully authentication checks are still + // done by some reverse proxy). + auth: InstanceAuthConfigurationMessage; + + // 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; + } + + // PATCH /private/instances/$INSTANCE + interface InstanceReconfigurationMessage { + + // Merchant name corresponding to this instance. + name: string; + + // Type of the user (business or individual). + // Defaults to 'business'. Should become mandatory field + // in the future, left as optional for API compatibility for now. + 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). + 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; + } + + // GET /private/instances + interface InstancesResponse { + // List of instances that are present in the backend (see Instance) + instances: Instance[]; + } + + interface Instance { + // Merchant name corresponding to this instance. + name: string; + + // 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) + id: string; + + // Public key of the merchant/instance, in Crockford Base32 encoding. + merchant_pub: EddsaPublicKey; + + // List of the payment targets supported by this instance. Clients can + // specify the desired payment target in /order requests. Note that + // front-ends do not have to support wallets selecting payment targets. + 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 + interface LoginTokenRequest { + // Scope of the token (which kinds of operations it will allow) + scope: "readonly" | "write"; + + // Server may impose its own upper bound + // on the token validity duration + duration?: RelativeTime; + + // Can this token be refreshed? + // Defaults to false. + refreshable?: boolean; + } + interface LoginTokenSuccessResponse { + // The login token that can be used to access resources + // that are in scope for some time. Must be prefixed + // with "Bearer " when used in the "Authorization" HTTP header. + // Will already begin with the RFC 8959 prefix. + token: string; + + // Scope of the token (which kinds of operations it will allow) + scope: "readonly" | "write"; + + // Server may impose its own upper bound + // on the token validity duration + expiration: Timestamp; + + // Can this token be refreshed? + refreshable: boolean; + } + } + + namespace KYC { + //GET /private/instances/$INSTANCE/kyc + interface AccountKycRedirects { + // Array of pending KYCs. + pending_kycs: MerchantAccountKycRedirect[]; + + // Array of exchanges with no reply. + timeout_kycs: ExchangeKycTimeout[]; + } + interface MerchantAccountKycRedirect { + // URL that the user should open in a browser to + // proceed with the KYC process (as returned + // by the exchange's /kyc-check/ endpoint). + // Optional, missing if the account is blocked + // due to AML and not due to KYC. + kyc_url?: string; + + // Base URL of the exchange this is about. + exchange_url: string; + + // AML status of the account. + aml_status: number; + + // Our bank wire account this is about. + payto_uri: string; + } + interface ExchangeKycTimeout { + // Base URL of the exchange this is about. + exchange_url: string; + + // Numeric error code indicating errors the exchange + // returned, or TALER_EC_INVALID for none. + exchange_code: number; + + // HTTP status code returned by the exchange when we asked for + // information about the KYC status. + // 0 if there was no response at all. + exchange_http_status: number; + } + + } + + namespace BankAccounts { + + interface AccountAddDetails { + + // payto:// URI of the account. + payto_uri: string; + + // 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; + + } + + 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: string; + + // Hash over the wire details (including over the salt) + h_wire: HashCode; + + // salt used to compute h_wire + salt: HashCode; + + // 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. + credit_facade_credentials?: FacadeCredentials; + + // true if this account is active, + // false if it is historic. + active: boolean; + } + + } + + namespace Products { + // POST /private/products + interface ProductAddDetail { + // product ID to use. + product_id: string; + + // Human-readable product description. + description: string; + + // Map from IETF BCP 47 language tags to localized descriptions + description_i18n: { [lang_tag: string]: string }; + + // unit in which the product is measured (liters, kilograms, packages, etc.) + unit: string; + + // The price for one unit of the product. Zero is used + // to imply that this product is not sold separately, or + // that the price is not fixed, and must be supplied by the + // front-end. If non-zero, this price MUST include applicable + // taxes. + price: Amount; + + // An optional base64-encoded product image + image: ImageDataUrl; + + // a list of taxes paid by the merchant for one unit of this product + taxes: Tax[]; + + // Number of units of the product in stock in sum in total, + // including all existing sales ever. Given in product-specific + // units. + // A value of -1 indicates "infinite" (i.e. for "electronic" books). + total_stock: Integer; + + // Identifies where the product is in stock. + address: Location; + + // Identifies when we expect the next restocking to happen. + next_restock?: Timestamp; + + // Minimum age buyer must have (in years). Default is 0. + minimum_age?: Integer; + } + // PATCH /private/products/$PRODUCT_ID + interface ProductPatchDetail { + // Human-readable product description. + description: string; + + // Map from IETF BCP 47 language tags to localized descriptions + description_i18n: { [lang_tag: string]: string }; + + // unit in which the product is measured (liters, kilograms, packages, etc.) + unit: string; + + // The price for one unit of the product. Zero is used + // to imply that this product is not sold separately, or + // that the price is not fixed, and must be supplied by the + // front-end. If non-zero, this price MUST include applicable + // taxes. + price: Amount; + + // An optional base64-encoded product image + image: ImageDataUrl; + + // a list of taxes paid by the merchant for one unit of this product + taxes: Tax[]; + + // Number of units of the product in stock in sum in total, + // including all existing sales ever. Given in product-specific + // units. + // A value of -1 indicates "infinite" (i.e. for "electronic" books). + total_stock: Integer; + + // Number of units of the product that were lost (spoiled, stolen, etc.) + total_lost: Integer; + + // Identifies where the product is in stock. + address: Location; + + // Identifies when we expect the next restocking to happen. + next_restock?: Timestamp; + + // Minimum age buyer must have (in years). Default is 0. + minimum_age?: Integer; + } + + // GET /private/products + interface InventorySummaryResponse { + // List of products that are present in the inventory + products: InventoryEntry[]; + } + interface InventoryEntry { + // Product identifier, as found in the product. + product_id: string; + } + + // GET /private/products/$PRODUCT_ID + interface ProductDetail { + // Human-readable product description. + description: string; + + // Map from IETF BCP 47 language tags to localized descriptions + description_i18n: { [lang_tag: string]: string }; + + // unit in which the product is measured (liters, kilograms, packages, etc.) + unit: string; + + // The price for one unit of the product. Zero is used + // to imply that this product is not sold separately, or + // that the price is not fixed, and must be supplied by the + // front-end. If non-zero, this price MUST include applicable + // taxes. + price: Amount; + + // An optional base64-encoded product image + image: ImageDataUrl; + + // a list of taxes paid by the merchant for one unit of this product + taxes: Tax[]; + + // Number of units of the product in stock in sum in total, + // including all existing sales ever. Given in product-specific + // units. + // A value of -1 indicates "infinite" (i.e. for "electronic" books). + total_stock: Integer; + + // Number of units of the product that have already been sold. + total_sold: Integer; + + // Number of units of the product that were lost (spoiled, stolen, etc.) + total_lost: Integer; + + // Identifies where the product is in stock. + address: Location; + + // Identifies when we expect the next restocking to happen. + next_restock?: Timestamp; + + // Minimum age buyer must have (in years). Default is 0. + minimum_age?: Integer; + } + + // POST /private/products/$PRODUCT_ID/lock + interface LockRequest { + // UUID that identifies the frontend performing the lock + // It is suggested that clients use a timeflake for this, + // see https://github.com/anthonynsimon/timeflake + lock_uuid: UUID; + + // How long does the frontend intend to hold the lock + duration: RelativeTime; + + // How many units should be locked? + quantity: Integer; + } + + // DELETE /private/products/$PRODUCT_ID + } + + namespace Orders { + type MerchantOrderStatusResponse = + | CheckPaymentPaidResponse + | CheckPaymentClaimedResponse + | CheckPaymentUnpaidResponse; + interface CheckPaymentPaidResponse { + // The customer paid for this contract. + order_status: "paid"; + + // Was the payment refunded (even partially)? + refunded: boolean; + + // True if there are any approved refunds that the wallet has + // not yet obtained. + refund_pending: boolean; + + // Did the exchange wire us the funds? + wired: boolean; + + // Total amount the exchange deposited into our bank account + // for this contract, excluding fees. + deposit_total: Amount; + + // Numeric error code indicating errors the exchange + // encountered tracking the wire transfer for this purchase (before + // we even got to specific coin issues). + // 0 if there were no issues. + exchange_ec: number; + + // HTTP status code returned by the exchange when we asked for + // information to track the wire transfer for this purchase. + // 0 if there were no issues. + exchange_hc: number; + + // Total amount that was refunded, 0 if refunded is false. + refund_amount: Amount; + + // Contract terms. + contract_terms: ContractTerms; + + // The wire transfer status from the exchange for this order if + // available, otherwise empty array. + wire_details: TransactionWireTransfer[]; + + // Reports about trouble obtaining wire transfer details, + // empty array if no trouble were encountered. + wire_reports: TransactionWireReport[]; + + // The refund details for this order. One entry per + // refunded coin; empty array if there are no refunds. + refund_details: RefundDetails[]; + + // Status URL, can be used as a redirect target for the browser + // to show the order QR code / trigger the wallet. + order_status_url: string; + } + interface CheckPaymentClaimedResponse { + // A wallet claimed the order, but did not yet pay for the contract. + order_status: "claimed"; + + // Contract terms. + contract_terms: ContractTerms; + } + interface CheckPaymentUnpaidResponse { + // The order was neither claimed nor paid. + order_status: "unpaid"; + + // when was the order created + creation_time: Timestamp; + + // Order summary text. + summary: string; + + // Total amount of the order (to be paid by the customer). + total_amount: Amount; + + // URI that the wallet must process to complete the payment. + taler_pay_uri: string; + + // Alternative order ID which was paid for already in the same session. + // Only given if the same product was purchased before in the same session. + already_paid_order_id?: string; + + // Fulfillment URL of an already paid order. Only given if under this + // session an already paid order with a fulfillment URL exists. + already_paid_fulfillment_url?: string; + + // Status URL, can be used as a redirect target for the browser + // to show the order QR code / trigger the wallet. + order_status_url: string; + + // We do we NOT return the contract terms here because they may not + // exist in case the wallet did not yet claim them. + } + interface RefundDetails { + // Reason given for the refund. + reason: string; + + // When was the refund approved. + timestamp: Timestamp; + + // Set to true if a refund is still available for the wallet for this payment. + pending: boolean; + + // Total amount that was refunded (minus a refund fee). + amount: Amount; + } + interface TransactionWireTransfer { + // Responsible exchange. + exchange_url: string; + + // 32-byte wire transfer identifier. + wtid: Base32; + + // Execution time of the wire transfer. + execution_time: Timestamp; + + // Total amount that has been wire transferred + // to the merchant. + amount: Amount; + + // Was this transfer confirmed by the merchant via the + // POST /transfers API, or is it merely claimed by the exchange? + confirmed: boolean; + } + interface TransactionWireReport { + // Numerical error code. + code: number; + + // Human-readable error description. + hint: string; + + // Numerical error code from the exchange. + exchange_ec: number; + + // HTTP status code received from the exchange. + exchange_hc: number; + + // Public key of the coin for which we got the exchange error. + coin_pub: CoinPublicKey; + } + + interface OrderHistory { + // timestamp-sorted array of all orders matching the query. + // The order of the sorting depends on the sign of delta. + orders: OrderHistoryEntry[]; + } + interface OrderHistoryEntry { + // order ID of the transaction related to this entry. + order_id: string; + + // row ID of the order in the database + row_id: number; + + // when the order was created + timestamp: Timestamp; + + // the amount of money the order is for + amount: Amount; + + // the summary of the order + summary: string; + + // whether some part of the order is refundable, + // that is the refund deadline has not yet expired + // and the total amount refunded so far is below + // the value of the original transaction. + refundable: boolean; + + // whether the order has been paid or not + paid: boolean; + } + + interface PostOrderRequest { + // The order must at least contain the minimal + // order detail, but can override all + order: Order; + + // if set, the backend will then set the refund deadline to the current + // time plus the specified delay. If it's not set, refunds will not be + // possible. + refund_delay?: RelativeTime; + + // specifies the payment target preferred by the client. Can be used + // to select among the various (active) wire methods supported by the instance. + payment_target?: string; + + // specifies that some products are to be included in the + // order from the inventory. For these inventory management + // is performed (so the products must be in stock) and + // details are completed from the product data of the backend. + inventory_products?: MinimalInventoryProduct[]; + + // Specifies a lock identifier that was used to + // lock a product in the inventory. Only useful if + // manage_inventory is set. Used in case a frontend + // reserved quantities of the individual products while + // the shopping card was being built. Multiple UUIDs can + // be used in case different UUIDs were used for different + // products (i.e. in case the user started with multiple + // shopping sessions that were combined during checkout). + lock_uuids?: UUID[]; + + // Should a token for claiming the order be generated? + // False can make sense if the ORDER_ID is sufficiently + // high entropy to prevent adversarial claims (like it is + // if the backend auto-generates one). Default is 'true'. + create_token?: boolean; + + // OTP device ID to associate with the order. + // This parameter is optional. + otp_id?: string; + } + type Order = MinimalOrderDetail | ContractTerms; + + interface MinimalOrderDetail { + // Amount to be paid by the customer + amount: Amount; + + // Short summary of the order + summary: string; + + // URL that will show that the order was successful after + // it has been paid for. Optional. When POSTing to the + // merchant, the placeholder "${ORDER_ID}" will be + // replaced with the actual order ID (useful if the + // order ID is generated server-side and needs to be + // in the URL). + fulfillment_url?: string; + } + + interface MinimalInventoryProduct { + // Which product is requested (here mandatory!) + product_id: string; + + // How many units of the product are requested + quantity: Integer; + } + interface PostOrderResponse { + // Order ID of the response that was just created + order_id: string; + + // Token that authorizes the wallet to claim the order. + // Provided only if "create_token" was set to 'true' + // in the request. + token?: ClaimToken; + } + interface OutOfStockResponse { + // Product ID of an out-of-stock item + product_id: string; + + // Requested quantity + requested_quantity: Integer; + + // Available quantity (must be below requested_quanitity) + available_quantity: Integer; + + // When do we expect the product to be again in stock? + // Optional, not given if unknown. + restock_expected?: Timestamp; + } + + interface ForgetRequest { + // Array of valid JSON paths to forgettable fields in the order's + // contract terms. + fields: string[]; + } + interface RefundRequest { + // Amount to be refunded + refund: Amount; + + // Human-readable refund justification + reason: string; + } + interface MerchantRefundResponse { + // URL (handled by the backend) that the wallet should access to + // trigger refund processing. + // taler://refund/... + taler_refund_uri: string; + + // Contract hash that a client may need to authenticate an + // HTTP request to obtain the above URI in a wallet-friendly way. + h_contract: HashCode; + } + } + + namespace Rewards { + // GET /private/reserves + interface RewardReserveStatus { + // Array of all known reserves (possibly empty!) + reserves: ReserveStatusEntry[]; + } + interface ReserveStatusEntry { + // Public key of the reserve + reserve_pub: EddsaPublicKey; + + // Timestamp when it was established + creation_time: Timestamp; + + // Timestamp when it expires + expiration_time: Timestamp; + + // Initial amount as per reserve creation call + merchant_initial_amount: Amount; + + // Initial amount as per exchange, 0 if exchange did + // not confirm reserve creation yet. + exchange_initial_amount: Amount; + + // Amount picked up so far. + pickup_amount: Amount; + + // Amount approved for rewards that exceeds the pickup_amount. + committed_amount: Amount; + + // Is this reserve active (false if it was deleted but not purged) + active: boolean; + } + + interface ReserveCreateRequest { + // Amount that the merchant promises to put into the reserve + initial_balance: Amount; + + // Exchange the merchant intends to use for reward + exchange_url: string; + + // Desired wire method, for example "iban" or "x-taler-bank" + wire_method: string; + } + interface ReserveCreateConfirmation { + // Public key identifying the reserve + reserve_pub: EddsaPublicKey; + + // Wire accounts of the exchange where to transfer the funds. + accounts: WireAccount[]; + } + interface RewardCreateRequest { + // Amount that the customer should be reward + amount: Amount; + + // Justification for giving the reward + justification: string; + + // URL that the user should be directed to after rewarding, + // will be included in the reward_token. + next_url: string; + } + interface RewardCreateConfirmation { + // Unique reward identifier for the reward that was created. + reward_id: HashCode; + + // taler://reward URI for the reward + taler_reward_uri: string; + + // URL that will directly trigger processing + // the reward when the browser is redirected to it + reward_status_url: string; + + // when does the reward expire + reward_expiration: Timestamp; + } + + interface ReserveDetail { + // Timestamp when it was established. + creation_time: Timestamp; + + // Timestamp when it expires. + expiration_time: Timestamp; + + // Initial amount as per reserve creation call. + merchant_initial_amount: Amount; + + // Initial amount as per exchange, 0 if exchange did + // not confirm reserve creation yet. + exchange_initial_amount: Amount; + + // Amount picked up so far. + pickup_amount: Amount; + + // Amount approved for rewards that exceeds the pickup_amount. + committed_amount: Amount; + + // Array of all rewards created by this reserves (possibly empty!). + // Only present if asked for explicitly. + rewards?: RewardStatusEntry[]; + + // Is this reserve active (false if it was deleted but not purged)? + active: boolean; + + // Array of wire accounts of the exchange that could + // be used to fill the reserve, can be NULL + // if the reserve is inactive or was already filled + accounts?: WireAccount[]; + + // URL of the exchange hosting the reserve, + // NULL if the reserve is inactive + exchange_url: string; + } + + interface RewardStatusEntry { + // Unique identifier for the reward. + reward_id: HashCode; + + // Total amount of the reward that can be withdrawn. + total_amount: Amount; + + // Human-readable reason for why the reward was granted. + reason: string; + } + + interface RewardDetails { + // Amount that we authorized for this reward. + total_authorized: Amount; + + // Amount that was picked up by the user already. + total_picked_up: Amount; + + // Human-readable reason given when authorizing the reward. + reason: string; + + // Timestamp indicating when the reward is set to expire (may be in the past). + expiration: Timestamp; + + // Reserve public key from which the reward is funded. + reserve_pub: EddsaPublicKey; + + // Array showing the pickup operations of the wallet (possibly empty!). + // Only present if asked for explicitly. + pickups?: PickupDetail[]; + } + interface PickupDetail { + // Unique identifier for the pickup operation. + pickup_id: HashCode; + + // Number of planchets involved. + num_planchets: Integer; + + // Total amount requested for this pickup_id. + requested_amount: Amount; + } + } + + namespace Transfers { + interface TransferList { + // list of all the transfers that fit the filter that we know + transfers: TransferDetails[]; + } + interface TransferDetails { + // how much was wired to the merchant (minus fees) + credit_amount: Amount; + + // raw wire transfer identifier identifying the wire transfer (a base32-encoded value) + wtid: string; + + // target account that received the wire transfer + payto_uri: string; + + // base URL of the exchange that made the wire transfer + exchange_url: string; + + // Serial number identifying the transfer in the merchant backend. + // Used for filgering via offset. + transfer_serial_id: number; + + // Time of the execution of the wire transfer by the exchange, according to the exchange + // Only provided if we did get an answer from the exchange. + execution_time?: Timestamp; + + // True if we checked the exchange's answer and are happy with it. + // False if we have an answer and are unhappy, missing if we + // do not have an answer from the exchange. + verified?: boolean; + + // True if the merchant uses the POST /transfers API to confirm + // that this wire transfer took place (and it is thus not + // something merely claimed by the exchange). + confirmed?: boolean; + } + + interface TransferInformation { + // how much was wired to the merchant (minus fees) + credit_amount: Amount; + + // raw wire transfer identifier identifying the wire transfer (a base32-encoded value) + wtid: WireTransferIdentifierRawP; + + // target account that received the wire transfer + payto_uri: string; + + // base URL of the exchange that made the wire transfer + exchange_url: string; + } + } + + namespace OTP { + interface OtpDeviceAddDetails { + // Device ID to use. + otp_device_id: string; + + // Human-readable description for the device. + otp_device_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_device_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 { + interface TemplateAddDetails { + // Template ID to use. + template_id: string; + + // Human-readable description for the template. + template_description: string; + + // OTP device ID. + // This parameter is optional. + otp_id?: string; + + // Additional information in a separate template. + template_contract: TemplateContractDetails; + } + interface TemplateContractDetails { + // Human-readable summary for the template. + summary?: string; + + // The price is imposed by the merchant and cannot be changed by the customer. + // This parameter is optional. + amount?: Amount; + + // Minimum age buyer must have (in years). Default is 0. + minimum_age: Integer; + + // The time the customer need to pay before his order will be deleted. + // It is deleted if the customer did not pay and if the duration is over. + pay_duration: RelativeTime; + } + interface TemplatePatchDetails { + // Human-readable description for the template. + template_description: string; + + // OTP device ID. + // This parameter is optional. + otp_id?: string; + + // Additional information in a separate template. + template_contract: TemplateContractDetails; + } + + interface TemplateSummaryResponse { + // List of templates that are present in our backend. + templates: TemplateEntry[]; + } + + interface TemplateEntry { + // Template identifier, as found in the template. + template_id: string; + + // Human-readable description for the template. + template_description: string; + } + + interface TemplateDetails { + // Human-readable description for the template. + template_description: string; + + // OTP device ID. + // This parameter is optional. + otp_id?: string; + + // Additional information in a separate template. + template_contract: TemplateContractDetails; + } + + interface UsingTemplateDetails { + // Subject of the template + summary?: string; + + // The amount entered by the customer. + amount?: Amount; + } + + interface UsingTemplateResponse { + // After enter the request. The user will be pay with a taler URL. + order_id: string; + token: string; + } + } + + namespace Webhooks { + type MerchantWebhookType = "pay" | "refund"; + interface WebhookAddDetails { + // Webhook ID to use. + webhook_id: string; + + // The event of the webhook: why the webhook is used. + event_type: MerchantWebhookType; + + // URL of the webhook where the customer will be redirected. + url: string; + + // Method used by the webhook + http_method: string; + + // Header template of the webhook + header_template?: string; + + // Body template by the webhook + body_template?: string; + } + interface WebhookPatchDetails { + // The event of the webhook: why the webhook is used. + event_type: string; + + // URL of the webhook where the customer will be redirected. + url: string; + + // Method used by the webhook + http_method: string; + + // Header template of the webhook + header_template?: string; + + // Body template by the webhook + body_template?: string; + } + interface WebhookSummaryResponse { + // List of webhooks that are present in our backend. + webhooks: WebhookEntry[]; + } + interface WebhookEntry { + // Webhook identifier, as found in the webhook. + webhook_id: string; + + // The event of the webhook: why the webhook is used. + event_type: string; + } + interface WebhookDetails { + // The event of the webhook: why the webhook is used. + event_type: string; + + // URL of the webhook where the customer will be redirected. + url: string; + + // Method used by the webhook + http_method: string; + + // Header template of the webhook + header_template?: string; + + // Body template by the webhook + body_template?: string; + } + } + + namespace TokenFamilies { + // Kind of the token family. + type TokenFamilyKind = "discount" | "subscription"; + + // POST /private/tokenfamilies + interface TokenFamilyAddDetail { + // Identifier for the token family consisting of unreserved characters + // according to RFC 3986. + slug: string; + + // Human-readable name for the token family. + name: string; + + // Human-readable description for the token family. + description: string; + + // Optional map from IETF BCP 47 language tags to localized descriptions. + description_i18n?: { [lang_tag: string]: string }; + + // Start time of the token family's validity period. + // If not specified, merchant backend will use the current time. + valid_after?: Timestamp; + + // End time of the token family's validity period. + valid_before: Timestamp; + + // Validity duration of an issued token. + duration: RelativeTime; + + // Kind of the token family. + kind: TokenFamilyKind; + } + + // PATCH /private/tokenfamilies/$SLUG + interface TokenFamilyPatchDetail { + // Human-readable name for the token family. + name: string; + + // Human-readable description for the token family. + description: string; + + // Optional map from IETF BCP 47 language tags to localized descriptions. + description_i18n: { [lang_tag: string]: string }; + + // Start time of the token family's validity period. + valid_after: Timestamp; + + // End time of the token family's validity period. + valid_before: Timestamp; + + // Validity duration of an issued token. + duration: RelativeTime; + } + + // GET /private/tokenfamilies + interface TokenFamilySummaryResponse { + // All configured token families of this instance. + token_families: TokenFamilyEntry[]; + } + + interface TokenFamilyEntry { + // Identifier for the token family consisting of unreserved characters + // according to RFC 3986. + slug: string; + + // Human-readable name for the token family. + name: string; + + // Start time of the token family's validity period. + valid_after: Timestamp; + + // End time of the token family's validity period. + valid_before: Timestamp; + + // Kind of the token family. + kind: TokenFamilyKind; + } + + // GET /private/tokenfamilies/$SLUG + interface TokenFamilyDetail { + // Identifier for the token family consisting of unreserved characters + // according to RFC 3986. + slug: string; + + // Human-readable name for the token family. + name: string; + + // Human-readable description for the token family. + description: string; + + // Optional map from IETF BCP 47 language tags to localized descriptions. + description_i18n?: { [lang_tag: string]: string }; + + // Start time of the token family's validity period. + valid_after: Timestamp; + + // End time of the token family's validity period. + valid_before: Timestamp; + + // Validity duration of an issued token. + duration: RelativeTime; + + // Kind of the token family. + kind: TokenFamilyKind; + + // How many tokens have been issued for this family. + issued: Integer; + + // How many tokens have been redeemed for this family. + redeemed: Integer; + } + + } + + interface ContractTerms { + // Human-readable description of the whole purchase + summary: string; + + // Map from IETF BCP 47 language tags to localized summaries + summary_i18n?: { [lang_tag: string]: string }; + + // Unique, free-form identifier for the proposal. + // Must be unique within a merchant instance. + // For merchants that do not store proposals in their DB + // before the customer paid for them, the order_id can be used + // by the frontend to restore a proposal from the information + // encoded in it (such as a short product identifier and timestamp). + order_id: string; + + // Total price for the transaction. + // The exchange will subtract deposit fees from that amount + // before transferring it to the merchant. + amount: Amount; + + // The URL for this purchase. Every time is is visited, the merchant + // will send back to the customer the same proposal. Clearly, this URL + // can be bookmarked and shared by users. + fulfillment_url?: string; + + // Maximum total deposit fee accepted by the merchant for this contract + max_fee: Amount; + + // List of products that are part of the purchase (see Product). + products: Product[]; + + // Time when this contract was generated + timestamp: TalerProtocolTimestamp; + + // After this deadline has passed, no refunds will be accepted. + refund_deadline: TalerProtocolTimestamp; + + // After this deadline, the merchant won't accept payments for the contact + pay_deadline: TalerProtocolTimestamp; + + // Transfer deadline for the exchange. Must be in the + // deposit permissions of coins used to pay for this order. + wire_transfer_deadline: TalerProtocolTimestamp; + + // Merchant's public key used to sign this proposal; this information + // is typically added by the backend Note that this can be an ephemeral key. + merchant_pub: EddsaPublicKey; + + // Base URL of the (public!) merchant backend API. + // Must be an absolute URL that ends with a slash. + merchant_base_url: string; + + // More info about the merchant, see below + merchant: Merchant; + + // The hash of the merchant instance's wire details. + h_wire: HashCode; + + // Wire transfer method identifier for the wire method associated with h_wire. + // The wallet may only select exchanges via a matching auditor if the + // exchange also supports this wire method. + // The wire transfer fees must be added based on this wire transfer method. + wire_method: string; + + // Any exchanges audited by these auditors are accepted by the merchant. + auditors: Auditor[]; + + // Exchanges that the merchant accepts even if it does not accept any auditors that audit them. + exchanges: Exchange[]; + + // Delivery location for (all!) products. + delivery_location?: Location; + + // Time indicating when the order should be delivered. + // May be overwritten by individual products. + delivery_date?: TalerProtocolTimestamp; + + // Nonce generated by the wallet and echoed by the merchant + // in this field when the proposal is generated. + nonce: string; + + // Specifies for how long the wallet should try to get an + // automatic refund for the purchase. If this field is + // present, the wallet should wait for a few seconds after + // the purchase and then automatically attempt to obtain + // a refund. The wallet should probe until "delay" + // after the payment was successful (i.e. via long polling + // or via explicit requests with exponential back-off). + // + // In particular, if the wallet is offline + // at that time, it MUST repeat the request until it gets + // one response from the merchant after the delay has expired. + // If the refund is granted, the wallet MUST automatically + // recover the payment. This is used in case a merchant + // knows that it might be unable to satisfy the contract and + // desires for the wallet to attempt to get the refund without any + // customer interaction. Note that it is NOT an error if the + // merchant does not grant a refund. + auto_refund?: RelativeTime; + + // Extra data that is only interpreted by the merchant frontend. + // Useful when the merchant needs to store extra information on a + // contract without storing it separately in their database. + extra?: any; + + // Minimum age buyer must have (in years). Default is 0. + minimum_age?: Integer; + } +} diff --git a/packages/merchant-backoffice-ui/src/hooks/bank.ts b/packages/merchant-backoffice-ui/src/hooks/bank.ts index 8857ad839..4c917fe9e 100644 --- a/packages/merchant-backoffice-ui/src/hooks/bank.ts +++ b/packages/merchant-backoffice-ui/src/hooks/bank.ts @@ -13,18 +13,19 @@ 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 { - useMerchantApiContext -} from "@gnu-taler/web-util/browser"; +import { useMerchantApiContext } from "@gnu-taler/web-util/browser"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util"; +import { + AccessToken, + TalerHttpError, + TalerMerchantManagementResultByMethod, +} from "@gnu-taler/taler-util"; import _useSWR, { SWRHook, mutate } from "swr"; import { useSessionContext } from "../context/session.js"; const useSWR = _useSWR as unknown as SWRHook; -export interface InstanceBankAccountFilter { -} +export interface InstanceBankAccountFilter {} export function revalidateInstanceBankAccounts() { return mutate( @@ -35,7 +36,9 @@ export function revalidateInstanceBankAccounts() { } export function useInstanceBankAccounts() { const { state: session } = useSessionContext(); - const { lib: { instance } } = useSessionContext(); + const { + lib: { instance }, + } = useSessionContext(); // const [offset, setOffset] = useState<string | undefined>(); @@ -57,19 +60,24 @@ export function useInstanceBankAccounts() { if (data.type !== "ok") return data; // return buildPaginatedResult(data.body.accounts, offset, setOffset, (d) => d.h_wire) + const filtered = data.body.accounts.filter((a) => a.active); + data.body.accounts = filtered; return data; } export function revalidateBankAccountDetails() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getBankAccountDetails", + (key) => + Array.isArray(key) && key[key.length - 1] === "getBankAccountDetails", undefined, { revalidate: true }, ); } export function useBankAccountDetails(h_wire: string) { const { state: session } = useSessionContext(); - const { lib: { instance } } = useSessionContext(); + const { + lib: { instance }, + } = useSessionContext(); async function fetcher([token, wireId]: [AccessToken, string]) { return await instance.getBankAccountDetails(token, wireId); diff --git a/packages/merchant-backoffice-ui/src/hooks/preference.ts b/packages/merchant-backoffice-ui/src/hooks/preference.ts index a21d2921c..fddbc6e08 100644 --- a/packages/merchant-backoffice-ui/src/hooks/preference.ts +++ b/packages/merchant-backoffice-ui/src/hooks/preference.ts @@ -27,6 +27,7 @@ import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; export interface Preferences { advanceOrderMode: boolean; + advanceInstanceMode: boolean; hideKycUntil: AbsoluteTime; hideMissingAccountUntil: AbsoluteTime; dateFormat: "ymd" | "dmy" | "mdy"; @@ -34,6 +35,7 @@ export interface Preferences { const defaultSettings: Preferences = { advanceOrderMode: false, + advanceInstanceMode: false, hideKycUntil: AbsoluteTime.never(), hideMissingAccountUntil: AbsoluteTime.never(), dateFormat: "ymd", @@ -42,6 +44,7 @@ const defaultSettings: Preferences = { export const codecForPreferences = (): Codec<Preferences> => buildCodecForObject<Preferences>() .property("advanceOrderMode", codecForBoolean()) + .property("advanceInstanceMode", codecForBoolean()) .property("hideKycUntil", codecForAbsoluteTime) .property("hideMissingAccountUntil", codecForAbsoluteTime) .property( diff --git a/packages/merchant-backoffice-ui/src/hooks/tokenfamily.ts b/packages/merchant-backoffice-ui/src/hooks/tokenfamily.ts new file mode 100644 index 000000000..221babf30 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/hooks/tokenfamily.ts @@ -0,0 +1,78 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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 { MerchantBackend } from "../declaration.js"; +import { useSessionContext } from "../context/session.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util"; +const useSWR = _useSWR as unknown as SWRHook; + +export function useInstanceTokenFamilies() { + const { state: session, lib: { instance } } = useSessionContext(); + + // const [offset, setOffset] = useState<number | undefined>(); + + async function fetcher([token, bid]: [AccessToken, number]) { + return await instance.listTokenFamilies(token, { + // limit: PAGINATED_LIST_REQUEST, + // offset: bid === undefined ? undefined: String(bid), + // order: "dec", + }); + } + + const { data, error } = useSWR< + TalerMerchantManagementResultByMethod<"listTokenFamilies">, + TalerHttpError + >([session.token, "offset", "listTokenFamilies"], fetcher); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return data; +} + +export function useTokenFamilyDetails(tokenFamilySlug: string) { + const { state: session } = useSessionContext(); + const { lib: { instance } } = useSessionContext(); + + async function fetcher([slug, token]: [string, AccessToken]) { + return await instance.getTokenFamilyDetails(token, slug); + } + + const { data, error } = useSWR< + TalerMerchantManagementResultByMethod<"getTokenFamilyDetails">, + TalerHttpError + >([tokenFamilySlug, session.token, "getTokenFamilyDetails"], fetcher); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return data; +} + +export interface TokenFamilyAPI { + createTokenFamily: ( + data: MerchantBackend.TokenFamilies.TokenFamilyAddDetail, + ) => Promise<void>; + updateTokenFamily: ( + slug: string, + data: MerchantBackend.TokenFamilies.TokenFamilyPatchDetail, + ) => Promise<void>; + deleteTokenFamily: (slug: string) => Promise<void>; +} diff --git a/packages/merchant-backoffice-ui/src/i18n/es.po b/packages/merchant-backoffice-ui/src/i18n/es.po index 2c4bc64a7..58a3745ac 100644 --- a/packages/merchant-backoffice-ui/src/i18n/es.po +++ b/packages/merchant-backoffice-ui/src/i18n/es.po @@ -17,8 +17,8 @@ msgstr "" "Project-Id-Version: Taler Wallet\n" "Report-Msgid-Bugs-To: taler@gnu.org\n" "POT-Creation-Date: 2016-11-23 00:00+0100\n" -"PO-Revision-Date: 2024-02-13 14:40+0000\n" -"Last-Translator: Stefan Kügel <skuegel@web.de>\n" +"PO-Revision-Date: 2024-06-28 00:57+0000\n" +"Last-Translator: Luis Avalos <avalos.diaz.0577@gmail.com>\n" "Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/" "merchant-backoffice/es/>\n" "Language: es\n" @@ -26,7 +26,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.2.1\n" +"X-Generator: Weblate 5.5.5\n" #: src/components/modal/index.tsx:71 #, c-format @@ -373,9 +373,9 @@ msgid "Description" msgstr "Descripcion" #: src/components/form/InputSearchProduct.tsx:94 -#, fuzzy, c-format +#, c-format msgid "Product" -msgstr "Productos" +msgstr "Producto" #: src/components/form/InputSearchProduct.tsx:95 #, c-format @@ -746,8 +746,8 @@ msgid "" "Time until which the wallet will automatically check for refunds without " "user interaction." msgstr "" -"Tiempo hasta el cual la billetera será automáticamente revisada por " -"reembolsos win interación por parte del usuario." +"Tiempo hasta el cual la cartera será automáticamente revisada por reembolsos " +"win interación por parte del usuario." #: src/paths/instance/orders/create/CreatePage.tsx:502 #, c-format @@ -775,6 +775,9 @@ msgid "" "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." msgstr "" +"Máximo total de comisiones por transferencia que el vendedor está dispuesto " +"a cubrir para este pedido. Los gastos de transferencia que superen este " +"importe correrán a cargo del cliente." #: src/paths/instance/orders/create/CreatePage.tsx:512 #, c-format @@ -788,6 +791,9 @@ msgid "" "determine the share of excess wire fees to be paid explicitly by the " "consumer." msgstr "" +"Factor por el que se dividen los comisiones por transferencia que superan el " +"umbral anterior para determinar la parte del exceso de comisiones por " +"transferencia que debe pagar explícitamente el consumidor." #: src/paths/instance/orders/create/CreatePage.tsx:517 #, fuzzy, c-format @@ -802,9 +808,9 @@ msgid "" msgstr "" #: src/paths/instance/orders/create/CreatePage.tsx:522 -#, fuzzy, c-format +#, c-format msgid "Minimum age required" -msgstr "Login necesario" +msgstr "Edad mínima requerida" #: src/paths/instance/orders/create/CreatePage.tsx:523 #, c-format @@ -812,11 +818,14 @@ msgid "" "Any value greater than 0 will limit the coins able be used to pay this " "contract. If empty the age restriction will be defined by the products" msgstr "" +"Cualquier valor superior a 0 limitará las monedas que se pueden utilizar " +"para pagar este contrato. Si está vacío, la restricción de edad vendrá " +"definida por los productos" #: src/paths/instance/orders/create/CreatePage.tsx:526 #, c-format msgid "Min age defined by the producs is %1$s" -msgstr "" +msgstr "La edad mínima definida por el producto es%1$s" #: src/paths/instance/orders/create/CreatePage.tsx:534 #, fuzzy, c-format @@ -827,11 +836,12 @@ msgstr "Información extra" #, c-format msgid "Custom information to be included in the contract for this order." msgstr "" +"Información personalizada que debe incluirse en el contrato para este pedido." #: src/paths/instance/orders/create/CreatePage.tsx:541 #, c-format msgid "You must enter a value in JavaScript Object Notation (JSON)." -msgstr "" +msgstr "Debes introducir un valor en JavaScript Object Notation (JSON)." #: src/components/picker/DurationPicker.tsx:55 #, c-format @@ -854,39 +864,39 @@ msgid "seconds" msgstr "segundos" #: src/components/form/InputDuration.tsx:53 -#, fuzzy, c-format +#, c-format msgid "forever" -msgstr "nunca" +msgstr "por siempre" #: src/components/form/InputDuration.tsx:62 #, c-format msgid "%1$sM" -msgstr "" +msgstr "%1$sM" #: src/components/form/InputDuration.tsx:64 #, c-format msgid "%1$sY" -msgstr "" +msgstr "%1$sA" #: src/components/form/InputDuration.tsx:66 #, c-format msgid "%1$sd" -msgstr "" +msgstr "%1$sd" #: src/components/form/InputDuration.tsx:68 #, c-format msgid "%1$sh" -msgstr "" +msgstr "%1$sh" #: src/components/form/InputDuration.tsx:70 #, c-format msgid "%1$smin" -msgstr "" +msgstr "%1$smin" #: src/components/form/InputDuration.tsx:72 #, c-format msgid "%1$ssec" -msgstr "" +msgstr "%1$sseg" #: src/paths/instance/orders/list/Table.tsx:75 #, c-format @@ -894,9 +904,9 @@ msgid "Orders" msgstr "Órdenes" #: src/paths/instance/orders/list/Table.tsx:81 -#, fuzzy, c-format +#, c-format msgid "create order" -msgstr "creado" +msgstr "crear orden" #: src/paths/instance/orders/list/Table.tsx:147 #, c-format @@ -1027,6 +1037,7 @@ msgstr "Máxima comisión" #, c-format msgid "maximum total deposit fee accepted by the merchant for this contract" msgstr "" +"tasa máxima total de depósito aceptada por el comerciante para este contrato" #: src/paths/instance/orders/details/DetailPage.tsx:93 #, c-format @@ -1036,7 +1047,7 @@ msgstr "Impuesto de transferencia máximo" #: src/paths/instance/orders/details/DetailPage.tsx:94 #, c-format msgid "maximum wire fee accepted by the merchant" -msgstr "" +msgstr "comisión máxima por transferencia aceptada por el comerciante" #: src/paths/instance/orders/details/DetailPage.tsx:100 #, c-format @@ -1053,23 +1064,23 @@ msgstr "Creado en" #: src/paths/instance/orders/details/DetailPage.tsx:106 #, c-format msgid "time when this contract was generated" -msgstr "" +msgstr "momento en que se generó este contrato" #: src/paths/instance/orders/details/DetailPage.tsx:112 #, c-format msgid "after this deadline has passed no refunds will be accepted" -msgstr "" +msgstr "pasado este plazo no se aceptarán devoluciones" #: src/paths/instance/orders/details/DetailPage.tsx:118 #, c-format msgid "" "after this deadline, the merchant won't accept payments for the contract" -msgstr "" +msgstr "pasado este plazo, el comerciante no aceptará pagos por el contrato" #: src/paths/instance/orders/details/DetailPage.tsx:124 #, c-format msgid "transfer deadline for the exchange" -msgstr "" +msgstr "plazo de transferencia para el intercambio" #: src/paths/instance/orders/details/DetailPage.tsx:130 #, c-format @@ -1079,7 +1090,7 @@ msgstr "" #: src/paths/instance/orders/details/DetailPage.tsx:136 #, c-format msgid "where the order will be delivered" -msgstr "" +msgstr "dónde se entregará el pedido" #: src/paths/instance/orders/details/DetailPage.tsx:144 #, fuzzy, c-format @@ -1091,6 +1102,8 @@ msgstr "Plazo de reembolso automático" msgid "" "how long the wallet should try to get an automatic refund for the purchase" msgstr "" +"cuánto tiempo debe intentar la cartera obtener el reembolso automático de la " +"compra" #: src/paths/instance/orders/details/DetailPage.tsx:150 #, fuzzy, c-format @@ -1101,6 +1114,7 @@ msgstr "Información extra" #, c-format msgid "extra data that is only interpreted by the merchant frontend" msgstr "" +"datos adicionales que solo son interpretados por la interfaz del comerciante" #: src/paths/instance/orders/details/DetailPage.tsx:219 #, c-format @@ -1153,9 +1167,9 @@ msgid "refunded" msgstr "reembolzado" #: src/paths/instance/orders/details/DetailPage.tsx:480 -#, fuzzy, c-format +#, c-format msgid "refund order" -msgstr "reembolzado" +msgstr "reembolsado" #: src/paths/instance/orders/details/DetailPage.tsx:481 #, fuzzy, c-format @@ -1170,12 +1184,12 @@ msgstr "reembolzar" #: src/paths/instance/orders/details/DetailPage.tsx:553 #, c-format msgid "Refunded amount" -msgstr "Monto reembolzado" +msgstr "Monto reembolsado" #: src/paths/instance/orders/details/DetailPage.tsx:560 -#, fuzzy, c-format +#, c-format msgid "Refund taken" -msgstr "Reembolzado" +msgstr "Reembolsado" #: src/paths/instance/orders/details/DetailPage.tsx:570 #, fuzzy, c-format @@ -1238,27 +1252,27 @@ msgstr "No se pudo create el reembolso" #: src/paths/instance/orders/list/ListPage.tsx:78 #, c-format msgid "select date to show nearby orders" -msgstr "" +msgstr "seleccione la fecha para mostrar pedidos cercanos" #: src/paths/instance/orders/list/ListPage.tsx:94 -#, fuzzy, c-format +#, c-format msgid "order id" -msgstr "ir a id de orden" +msgstr "ID de la orden" #: src/paths/instance/orders/list/ListPage.tsx:100 #, c-format msgid "jump to order with the given order ID" -msgstr "" +msgstr "saltar al pedido con el ID de pedido proporcionado" #: src/paths/instance/orders/list/ListPage.tsx:122 #, c-format msgid "remove all filters" -msgstr "" +msgstr "eliminar todos los filtros" #: src/paths/instance/orders/list/ListPage.tsx:132 #, c-format msgid "only show paid orders" -msgstr "" +msgstr "mostrar sólo pedidos pagados" #: src/paths/instance/orders/list/ListPage.tsx:135 #, c-format @@ -1281,6 +1295,8 @@ msgid "" "only show orders where customers paid, but wire payments from payment " "provider are still pending" msgstr "" +"mostrar sólo los pedidos en los que los clientes han pagado, pero los pagos " +"por transferencia del proveedor de pago siguen pendientes" #: src/paths/instance/orders/list/ListPage.tsx:155 #, c-format @@ -1290,7 +1306,7 @@ msgstr "No transferido" #: src/paths/instance/orders/list/ListPage.tsx:170 #, c-format msgid "clear date filter" -msgstr "" +msgstr "borrar filtro de fechas" #: src/paths/instance/orders/list/ListPage.tsx:184 #, c-format @@ -1303,9 +1319,9 @@ msgid "Enter an order id" msgstr "ir a id de orden" #: src/paths/instance/orders/list/index.tsx:111 -#, fuzzy, c-format +#, c-format msgid "order not found" -msgstr "Servidor no encontrado" +msgstr "Orden no encontrada" #: src/paths/instance/orders/list/index.tsx:178 #, fuzzy, c-format @@ -1323,6 +1339,8 @@ msgid "" "click here to configure the stock of the product, leave it as is and the " "backend will not control stock" msgstr "" +"pulse aquí para configurar el stock del producto, déjelo como está y el " +"backend no controlará el stock" #: src/components/form/InputStock.tsx:109 #, c-format @@ -1332,7 +1350,7 @@ msgstr "Administrar stock" #: src/components/form/InputStock.tsx:115 #, c-format msgid "this product has been configured without stock control" -msgstr "" +msgstr "este producto se ha configurado sin control de existencias" #: src/components/form/InputStock.tsx:119 #, c-format @@ -1362,7 +1380,7 @@ msgstr "Actual" #: src/components/form/InputStock.tsx:196 #, c-format msgid "remove stock control for this product" -msgstr "" +msgstr "eliminar el control de existencias de este producto" #: src/components/form/InputStock.tsx:202 #, c-format @@ -1383,26 +1401,27 @@ msgstr "Dirección de entrega" #, c-format msgid "product identification to use in URLs (for internal use only)" msgstr "" +"Identificación del producto para usar en las URL (solo para uso interno)" #: src/components/product/ProductForm.tsx:139 #, c-format msgid "illustration of the product for customers" -msgstr "" +msgstr "ilustración del producto para los clientes" #: src/components/product/ProductForm.tsx:145 #, c-format msgid "product description for customers" -msgstr "" +msgstr "descripción del producto para los clientes" #: src/components/product/ProductForm.tsx:149 #, c-format msgid "Age restricted" -msgstr "" +msgstr "Restricción de edad" #: src/components/product/ProductForm.tsx:150 #, c-format msgid "is this product restricted for customer below certain age?" -msgstr "" +msgstr "¿este producto está restringido para clientes menores de cierta edad?" #: src/components/product/ProductForm.tsx:155 #, c-format @@ -1410,12 +1429,16 @@ msgid "" "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 " "items, 5 meters) for customers" msgstr "" +"unidad que describe la cantidad de producto vendido (por ejemplo, 2 " +"kilogramos, 5 litros, 3 artículos, 5 metros) para los clientes" #: src/components/product/ProductForm.tsx:160 #, c-format msgid "" "sale price for customers, including taxes, for above units of the product" msgstr "" +"precio de venta para los clientes, impuestos incluidos, por encima de las " +"unidades del producto" #: src/components/product/ProductForm.tsx:164 #, c-format @@ -1427,16 +1450,18 @@ msgstr "Existencias" msgid "" "product inventory for products with finite supply (for internal use only)" msgstr "" +"inventario de productos para productos con suministro finito (sólo para uso " +"interno)" #: src/components/product/ProductForm.tsx:171 #, c-format msgid "taxes included in the product price, exposed to customers" -msgstr "" +msgstr "impuestos incluidos en el precio del producto, expuestos a los clientes" #: src/paths/instance/products/create/CreatePage.tsx:66 #, c-format msgid "Need to complete marked fields" -msgstr "" +msgstr "Necesita completar los campos marcados" #: src/paths/instance/products/create/index.tsx:51 #, c-format @@ -1451,7 +1476,7 @@ msgstr "Productos" #: src/paths/instance/products/list/Table.tsx:73 #, c-format msgid "add product to inventory" -msgstr "" +msgstr "añadir producto al inventario" #: src/paths/instance/products/list/Table.tsx:137 #, c-format @@ -1486,27 +1511,27 @@ msgstr "Actualizar" #: src/paths/instance/products/list/Table.tsx:260 #, c-format msgid "remove this product from the database" -msgstr "" +msgstr "eliminar este producto de la base de datos" #: src/paths/instance/products/list/Table.tsx:331 #, c-format msgid "update the product with new price" -msgstr "" +msgstr "actualizar el producto con el nuevo precio" #: src/paths/instance/products/list/Table.tsx:341 #, c-format msgid "update product with new price" -msgstr "" +msgstr "actualizar producto con nuevo precio" #: src/paths/instance/products/list/Table.tsx:399 #, c-format msgid "add more elements to the inventory" -msgstr "" +msgstr "añadir más elementos al inventario" #: src/paths/instance/products/list/Table.tsx:404 #, c-format msgid "report elements lost in the inventory" -msgstr "" +msgstr "informar de elementos perdidos en el inventario" #: src/paths/instance/products/list/Table.tsx:409 #, fuzzy, c-format @@ -1734,7 +1759,7 @@ msgstr "" #: src/paths/instance/reserves/list/Table.tsx:210 #, c-format msgid "authorize new tip from selected reserve" -msgstr "" +msgstr "autorizar nueva punta de reserva seleccionada" #: src/paths/instance/reserves/list/Table.tsx:237 #, fuzzy, c-format @@ -1770,32 +1795,32 @@ msgstr "no puede ser vacío" #: src/paths/instance/templates/create/CreatePage.tsx:100 #, c-format msgid "to short" -msgstr "" +msgstr "demasiado corta" #: src/paths/instance/templates/create/CreatePage.tsx:108 #, c-format msgid "just letters and numbers from 2 to 7" -msgstr "" +msgstr "sólo letras y números del 2 al 7" #: src/paths/instance/templates/create/CreatePage.tsx:110 #, c-format msgid "size of the key should be 32" -msgstr "" +msgstr "el tamaño de la clave debe ser 32" #: src/paths/instance/templates/create/CreatePage.tsx:137 #, c-format msgid "Identifier" -msgstr "" +msgstr "Identificador" #: src/paths/instance/templates/create/CreatePage.tsx:138 #, c-format msgid "Name of the template in URLs." -msgstr "" +msgstr "Nombre de la plantilla en las URL." #: src/paths/instance/templates/create/CreatePage.tsx:144 #, c-format msgid "Describe what this template stands for" -msgstr "" +msgstr "Describa lo que representa esta plantilla" #: src/paths/instance/templates/create/CreatePage.tsx:149 #, fuzzy, c-format @@ -1805,7 +1830,7 @@ msgstr "Estado de orden" #: src/paths/instance/templates/create/CreatePage.tsx:150 #, c-format msgid "If specified, this template will create order with the same summary" -msgstr "" +msgstr "Si se especifica, esta plantilla creará pedidos con el mismo resumen" #: src/paths/instance/templates/create/CreatePage.tsx:154 #, fuzzy, c-format @@ -1815,7 +1840,7 @@ msgstr "precio unitario" #: src/paths/instance/templates/create/CreatePage.tsx:155 #, c-format msgid "If specified, this template will create order with the same price" -msgstr "" +msgstr "Si se especifica, esta plantilla creará pedidos con el mismo precio" #: src/paths/instance/templates/create/CreatePage.tsx:159 #, c-format @@ -1825,7 +1850,7 @@ msgstr "Edad mínima" #: src/paths/instance/templates/create/CreatePage.tsx:161 #, c-format msgid "Is this contract restricted to some age?" -msgstr "" +msgstr "¿Este contrato está restringido a alguna edad?" #: src/paths/instance/templates/create/CreatePage.tsx:165 #, fuzzy, c-format @@ -1838,56 +1863,58 @@ msgid "" "How much time has the customer to complete the payment once the order was " "created." msgstr "" +"Cuánto tiempo tiene el cliente para completar el pago una vez creado el " +"pedido." #: src/paths/instance/templates/create/CreatePage.tsx:171 #, c-format msgid "Verification algorithm" -msgstr "" +msgstr "Algoritmo de verificación" #: src/paths/instance/templates/create/CreatePage.tsx:172 #, c-format msgid "Algorithm to use to verify transaction in offline mode" -msgstr "" +msgstr "Algoritmo a utilizar para verificar la transacción en modo offline" #: src/paths/instance/templates/create/CreatePage.tsx:180 #, c-format msgid "Point-of-sale key" -msgstr "" +msgstr "Clave punto de venta" #: src/paths/instance/templates/create/CreatePage.tsx:182 #, c-format msgid "Useful to validate the purchase" -msgstr "" +msgstr "Útil para validar la compra" #: src/paths/instance/templates/create/CreatePage.tsx:196 #, c-format msgid "generate random secret key" -msgstr "" +msgstr "generar clave secreta aleatoria" #: src/paths/instance/templates/create/CreatePage.tsx:203 #, c-format msgid "random" -msgstr "" +msgstr "aleatorio" #: src/paths/instance/templates/create/CreatePage.tsx:208 #, c-format msgid "show secret key" -msgstr "" +msgstr "mostrar clave secreta" #: src/paths/instance/templates/create/CreatePage.tsx:209 #, c-format msgid "hide secret key" -msgstr "" +msgstr "ocultar clave secreta" #: src/paths/instance/templates/create/CreatePage.tsx:216 #, c-format msgid "hide" -msgstr "" +msgstr "ocultar" #: src/paths/instance/templates/create/CreatePage.tsx:218 #, c-format msgid "show" -msgstr "" +msgstr "mostrar" #: src/paths/instance/templates/create/index.tsx:52 #, fuzzy, c-format @@ -1902,7 +1929,7 @@ msgstr "Login necesario" #: src/paths/instance/templates/use/UsePage.tsx:58 #, c-format msgid "Order summary is required" -msgstr "" +msgstr "Se requiere resumen del pedido" #: src/paths/instance/templates/use/UsePage.tsx:86 #, fuzzy, c-format @@ -1912,7 +1939,7 @@ msgstr "cargar viejas transferencias" #: src/paths/instance/templates/use/UsePage.tsx:108 #, c-format msgid "Amount of the order" -msgstr "" +msgstr "Importe del pedido" #: src/paths/instance/templates/use/UsePage.tsx:113 #, fuzzy, c-format @@ -1930,16 +1957,19 @@ msgid "" "Here you can specify a default value for fields that are not fixed. Default " "values can be edited by the customer before the payment." msgstr "" +"Aquí puede especificar un valor por defecto para los campos que no son " +"fijos. Los valores por defecto pueden ser editados por el cliente antes del " +"pago." #: src/paths/instance/templates/qr/QrPage.tsx:148 -#, fuzzy, c-format +#, c-format msgid "Fixed amount" -msgstr "Monto reembolzado" +msgstr "Importe fijo" #: src/paths/instance/templates/qr/QrPage.tsx:149 -#, fuzzy, c-format +#, c-format msgid "Default amount" -msgstr "Monto reembolzado" +msgstr "Importe por defecto" #: src/paths/instance/templates/qr/QrPage.tsx:161 #, fuzzy, c-format @@ -1949,27 +1979,27 @@ msgstr "Estado de orden" #: src/paths/instance/templates/qr/QrPage.tsx:177 #, c-format msgid "Print" -msgstr "" +msgstr "Imprimir" #: src/paths/instance/templates/qr/QrPage.tsx:184 #, c-format msgid "Setup TOTP" -msgstr "" +msgstr "Configurar TOTP" #: src/paths/instance/templates/list/Table.tsx:65 #, c-format msgid "Templates" -msgstr "" +msgstr "Plantillas" #: src/paths/instance/templates/list/Table.tsx:70 #, c-format msgid "add new templates" -msgstr "" +msgstr "añadir nuevas plantillas" #: src/paths/instance/templates/list/Table.tsx:142 #, c-format msgid "load more templates before the first one" -msgstr "" +msgstr "cargar más plantillas antes de la primera" #: src/paths/instance/templates/list/Table.tsx:146 #, fuzzy, c-format @@ -1979,12 +2009,12 @@ msgstr "cargar nuevas transferencias" #: src/paths/instance/templates/list/Table.tsx:181 #, c-format msgid "delete selected templates from the database" -msgstr "" +msgstr "eliminar las plantillas seleccionadas de la base de datos" #: src/paths/instance/templates/list/Table.tsx:188 #, c-format msgid "use template to create new order" -msgstr "" +msgstr "utilizar la plantilla para crear un nuevo pedido" #: src/paths/instance/templates/list/Table.tsx:195 #, fuzzy, c-format @@ -1994,7 +2024,7 @@ msgstr "No se pudo create el reembolso" #: src/paths/instance/templates/list/Table.tsx:210 #, c-format msgid "load more templates after the last one" -msgstr "" +msgstr "cargar más plantillas después de la última" #: src/paths/instance/templates/list/Table.tsx:214 #, fuzzy, c-format @@ -2029,27 +2059,27 @@ msgstr "deberían ser iguales" #: src/paths/instance/webhooks/create/CreatePage.tsx:85 #, c-format msgid "Webhook ID to use" -msgstr "" +msgstr "ID de webhook a utilizar" #: src/paths/instance/webhooks/create/CreatePage.tsx:89 #, c-format msgid "Event" -msgstr "" +msgstr "Evento" #: src/paths/instance/webhooks/create/CreatePage.tsx:90 #, c-format msgid "The event of the webhook: why the webhook is used" -msgstr "" +msgstr "El evento del webhook: por qué se utiliza el webhook" #: src/paths/instance/webhooks/create/CreatePage.tsx:94 #, c-format msgid "Method" -msgstr "" +msgstr "Método" #: src/paths/instance/webhooks/create/CreatePage.tsx:95 #, c-format msgid "Method used by the webhook" -msgstr "" +msgstr "Método utilizado por el webhook" #: src/paths/instance/webhooks/create/CreatePage.tsx:99 #, c-format @@ -2059,12 +2089,12 @@ msgstr "URL" #: src/paths/instance/webhooks/create/CreatePage.tsx:100 #, c-format msgid "URL of the webhook where the customer will be redirected" -msgstr "" +msgstr "URL del webhook al que se redirigirá al cliente" #: src/paths/instance/webhooks/create/CreatePage.tsx:104 #, c-format msgid "Header" -msgstr "" +msgstr "Cabecera" #: src/paths/instance/webhooks/create/CreatePage.tsx:106 #, c-format @@ -2074,7 +2104,7 @@ msgstr "" #: src/paths/instance/webhooks/create/CreatePage.tsx:111 #, c-format msgid "Body" -msgstr "" +msgstr "Cuerpo" #: src/paths/instance/webhooks/create/CreatePage.tsx:112 #, c-format @@ -2104,17 +2134,17 @@ msgstr "cargar nuevas ordenes" #: src/paths/instance/webhooks/list/Table.tsx:151 #, c-format msgid "Event type" -msgstr "" +msgstr "Tipo de evento" #: src/paths/instance/webhooks/list/Table.tsx:176 #, c-format msgid "delete selected webhook from the database" -msgstr "" +msgstr "eliminar el webhook seleccionado de la base de datos" #: src/paths/instance/webhooks/list/Table.tsx:198 #, c-format msgid "load more webhooks after the last one" -msgstr "" +msgstr "cargar más webhooks después del último" #: src/paths/instance/webhooks/list/Table.tsx:202 #, fuzzy, c-format @@ -2154,17 +2184,17 @@ msgstr "La URL no tiene el formato correcto" #: src/paths/instance/transfers/create/CreatePage.tsx:98 #, c-format msgid "Credited bank account" -msgstr "" +msgstr "Abono en cuenta bancaria" #: src/paths/instance/transfers/create/CreatePage.tsx:100 #, c-format msgid "Select one account" -msgstr "" +msgstr "Selecciona una cuenta" #: src/paths/instance/transfers/create/CreatePage.tsx:101 #, c-format msgid "Bank account of the merchant where the payment was received" -msgstr "" +msgstr "Cuenta bancaria del comerciante donde se recibió el pago" #: src/paths/instance/transfers/create/CreatePage.tsx:105 #, fuzzy, c-format @@ -2177,6 +2207,8 @@ msgid "" "unique identifier of the wire transfer used by the exchange, must be 52 " "characters long" msgstr "" +"identificador único de la transferencia utilizado por la bolsa, debe tener " +"52 caracteres" #: src/paths/instance/transfers/create/CreatePage.tsx:112 #, c-format @@ -2184,16 +2216,18 @@ msgid "" "Base URL of the exchange that made the transfer, should have been in the " "wire transfer subject" msgstr "" +"URL base de la bolsa que realizó la transferencia, debería haber estado en " +"el asunto de la transferencia bancaria" #: src/paths/instance/transfers/create/CreatePage.tsx:117 #, c-format msgid "Amount credited" -msgstr "" +msgstr "Monto abonado" #: src/paths/instance/transfers/create/CreatePage.tsx:118 #, c-format msgid "Actual amount that was wired to the merchant's bank account" -msgstr "" +msgstr "Monto real que se transfirió a la cuenta bancaria del comerciante" #: src/paths/instance/transfers/create/index.tsx:58 #, c-format @@ -2213,7 +2247,7 @@ msgstr "cargar nuevas transferencias" #: src/paths/instance/transfers/list/Table.tsx:129 #, c-format msgid "load more transfers before the first one" -msgstr "" +msgstr "cargar más transferencias antes de la primera" #: src/paths/instance/transfers/list/Table.tsx:133 #, c-format @@ -2283,7 +2317,7 @@ msgstr "Dirección de cuenta" #: src/paths/instance/transfers/list/ListPage.tsx:100 #, c-format msgid "only show wire transfers confirmed by the merchant" -msgstr "" +msgstr "mostrar sólo las transferencias confirmadas por el comerciante" #: src/paths/instance/transfers/list/ListPage.tsx:110 #, c-format @@ -2298,7 +2332,7 @@ msgstr "Verificado" #: src/paths/admin/create/CreatePage.tsx:69 #, c-format msgid "is not valid" -msgstr "" +msgstr "no es válido" #: src/paths/admin/create/CreatePage.tsx:94 #, fuzzy, c-format @@ -2388,12 +2422,12 @@ msgstr "Dirección de cuenta" #: src/components/form/InputPaytoForm.tsx:273 #, c-format msgid "Business Identifier Code." -msgstr "" +msgstr "Código de identificación de la empresa." #: src/components/form/InputPaytoForm.tsx:282 #, c-format msgid "Bank Account Number." -msgstr "" +msgstr "Número de cuenta bancaria." #: src/components/form/InputPaytoForm.tsx:292 #, c-format @@ -2403,17 +2437,17 @@ msgstr "Interfaz de pago unificado." #: src/components/form/InputPaytoForm.tsx:301 #, c-format msgid "Bitcoin protocol." -msgstr "" +msgstr "Protocolo Bitcoin." #: src/components/form/InputPaytoForm.tsx:310 #, c-format msgid "Ethereum protocol." -msgstr "" +msgstr "Protocolo Ethereum." #: src/components/form/InputPaytoForm.tsx:319 #, c-format msgid "Interledger protocol." -msgstr "" +msgstr "Protocolo Interledger." #: src/components/form/InputPaytoForm.tsx:328 #, c-format @@ -2428,17 +2462,17 @@ msgstr "" #: src/components/form/InputPaytoForm.tsx:334 #, c-format msgid "Bank account." -msgstr "" +msgstr "Cuenta bancaria." #: src/components/form/InputPaytoForm.tsx:343 #, c-format msgid "Bank account owner's name." -msgstr "" +msgstr "Nombre del titular de la cuenta bancaria." #: src/components/form/InputPaytoForm.tsx:370 #, c-format msgid "No accounts yet." -msgstr "" +msgstr "Aún no hay cuentas." #: src/components/instance/DefaultInstanceFormFields.tsx:52 #, c-format @@ -2446,6 +2480,8 @@ msgid "" "Name of the instance in URLs. The 'default' instance is special in that it " "is used to administer other instances." msgstr "" +"Nombre de la instancia en URL. La instancia \"por defecto\" es especial, ya " +"que se utiliza para administrar otras instancias." #: src/components/instance/DefaultInstanceFormFields.tsx:58 #, fuzzy, c-format @@ -2455,7 +2491,7 @@ msgstr "Nombre de edificio" #: src/components/instance/DefaultInstanceFormFields.tsx:59 #, c-format msgid "Legal name of the business represented by this instance." -msgstr "" +msgstr "Nombre legal de la empresa representada por esta instancia." #: src/components/instance/DefaultInstanceFormFields.tsx:64 #, c-format @@ -2475,17 +2511,17 @@ msgstr "URL de sitio web" #: src/components/instance/DefaultInstanceFormFields.tsx:71 #, c-format msgid "URL." -msgstr "" +msgstr "URL." #: src/components/instance/DefaultInstanceFormFields.tsx:76 #, c-format msgid "Logo" -msgstr "" +msgstr "Logotipo" #: src/components/instance/DefaultInstanceFormFields.tsx:77 #, c-format msgid "Logo image." -msgstr "" +msgstr "Imagen del logotipo." #: src/components/instance/DefaultInstanceFormFields.tsx:82 #, c-format @@ -2495,7 +2531,7 @@ msgstr "Cuenta bancaria" #: src/components/instance/DefaultInstanceFormFields.tsx:83 #, c-format msgid "URI specifying bank account for crediting revenue." -msgstr "" +msgstr "URI que especifica la cuenta bancaria para acreditar los ingresos." #: src/components/instance/DefaultInstanceFormFields.tsx:88 #, c-format @@ -2507,6 +2543,8 @@ msgstr "Impuesto máximo de deposito por omisión" msgid "" "Maximum deposit fees this merchant is willing to pay per order by default." msgstr "" +"Comisiones de depósito máximas que este comerciante está dispuesto a pagar " +"por pedido por defecto." #: src/components/instance/DefaultInstanceFormFields.tsx:94 #, c-format @@ -2519,6 +2557,8 @@ msgid "" "Maximum wire fees this merchant is willing to pay per wire transfer by " "default." msgstr "" +"Comisiones de transferencia máximas que este comerciante está dispuesto a " +"pagar por transferencia por defecto." #: src/components/instance/DefaultInstanceFormFields.tsx:100 #, c-format @@ -2531,11 +2571,13 @@ msgid "" "Number of orders excess wire transfer fees will be divided by to compute per " "order surcharge." msgstr "" +"El número de pedidos que excedan las tarifas de transferencia bancaria se " +"dividirá para calcular el recargo por pedido." #: src/components/instance/DefaultInstanceFormFields.tsx:107 #, c-format msgid "Physical location of the merchant." -msgstr "" +msgstr "Ubicación física del comerciante." #: src/components/instance/DefaultInstanceFormFields.tsx:114 #, c-format @@ -2557,6 +2599,8 @@ msgstr "Retrazo de pago por omisión" msgid "" "Time customers have to pay an order before the offer expires by default." msgstr "" +"Tiempo que los clientes tienen para pagar un pedido antes de que caduque la " +"oferta de forma predeterminada." #: src/components/instance/DefaultInstanceFormFields.tsx:129 #, c-format @@ -2570,6 +2614,10 @@ msgid "" "enabling it to aggregate smaller payments into larger wire transfers and " "reducing wire fees." msgstr "" +"Tiempo máximo que se le permite a un intercambio retrasar la transferencia " +"de fondos al comerciante, lo que le permite agregar pagos más pequeños en " +"transferencias electrónicas más grandes y reducir las tarifas de " +"transferencia." #: src/paths/instance/update/UpdatePage.tsx:164 #, c-format @@ -2600,7 +2648,7 @@ msgstr "Login necesario" #: src/components/exception/login.tsx:80 #, c-format msgid "Please enter your access token." -msgstr "" +msgstr "Por favor, introduzca su clave de acceso." #: src/components/exception/login.tsx:108 #, fuzzy, c-format @@ -2610,7 +2658,7 @@ msgstr "Acceso denegado" #: src/InstanceRoutes.tsx:171 #, c-format msgid "The request to the backend take too long and was cancelled" -msgstr "" +msgstr "La petición al backend tardó demasiado y fue cancelada" #: src/InstanceRoutes.tsx:172 #, c-format diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx index a28992a2f..6a94109a0 100644 --- a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx @@ -25,7 +25,7 @@ import { createRFC8959AccessTokenPlain, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../../components/exception/AsyncButton.js"; import { @@ -34,6 +34,7 @@ import { } from "../../../components/form/FormProvider.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; import { SetTokenNewInstanceModal } from "../../../components/modal/index.js"; +import { usePreference } from "../../../hooks/preference.js"; import { INSTANCE_ID_REGEX } from "../../../utils/constants.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; @@ -64,13 +65,13 @@ function with_defaults(id?: string): Partial<Entity> { } export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { + const [pref, updatePref] = usePreference(); + const { i18n } = useTranslationContext(); const [value, valueHandler] = useState(with_defaults(forceId)); const [isTokenSet, updateIsTokenSet] = useState<boolean>(false); const [isTokenDialogActive, updateIsTokenDialogActive] = useState<boolean>(false); - const { i18n } = useTranslationContext(); - const errors: FormErrors<Entity> = { id: !value.id ? i18n.str`required` @@ -186,6 +187,34 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { </div> <section class="section is-main-section"> + <div class="tabs is-toggle is-fullwidth is-small"> + <ul> + <li + class={!pref.advanceInstanceMode ? "is-active" : ""} + onClick={() => { + updatePref("advanceInstanceMode", false); + }} + > + <a> + <span> + <i18n.Translate>Simple</i18n.Translate> + </span> + </a> + </li> + <li + class={pref.advanceInstanceMode ? "is-active" : ""} + onClick={() => { + updatePref("advanceInstanceMode", true); + }} + > + <a> + <span> + <i18n.Translate>Advanced</i18n.Translate> + </span> + </a> + </li> + </ul> + </div>{" "} <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> @@ -194,7 +223,11 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { object={value} valueHandler={valueHandler} > - <DefaultInstanceFormFields readonlyId={!!forceId} showId={true} /> + <DefaultInstanceFormFields + readonlyId={!!forceId} + showId={true} + showLessFields={!pref.advanceInstanceMode} + /> </FormProvider> <div class="level"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx index d0e7a83cd..61f62e631 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx @@ -19,7 +19,16 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + HttpStatusCode, + PaytoString, + PaytoUri, + TalerError, + TalerMerchantApi, + TranslatedString, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -31,11 +40,16 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; -import { ImportingAccountModal } from "../../../../components/modal/index.js"; +import { + CompareAccountsModal, + ImportingAccountModal, +} from "../../../../components/modal/index.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { safeConvertURL } from "../update/UpdatePage.js"; +import { testRevenueAPI } from "./index.js"; +import { InputToggle } from "../../../../components/form/InputToggle.js"; -type Entity = TalerMerchantApi.AccountAddDetails & { repeatPassword: string }; +type Entity = TalerMerchantApi.AccountAddDetails & { verified?: boolean }; interface Props { onCreate: (d: TalerMerchantApi.AccountAddDetails) => Promise<void>; @@ -50,6 +64,14 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { const [importing, setImporting] = useState(false); const [state, setState] = useState<Partial<Entity>>({}); const facadeURL = safeConvertURL(state.credit_facade_url); + + const [revenuePayto, setRevenuePayto] = useState<PaytoUri | undefined>( + // parsePaytoUri("payto://x-taler-bank/asd.com:1010/asd/pepe"), + undefined, + ); + const [testError, setTestError] = useState<TranslatedString | undefined>( + undefined, + ); const errors: FormErrors<Entity> = { payto_uri: !state.payto_uri ? i18n.str`required` : undefined, @@ -78,13 +100,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { : facadeURL.hash ? i18n.str`URL should not hash param` : 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( @@ -117,25 +132,73 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { credit_facade_url, }); }; - return ( - <div> - {importing && <ImportingAccountModal onCancel={()=> {setImporting(false)}} onConfirm={(ac) => { - state.payto_uri = ac.accountURI - const u = new URL(ac.infoURL) - u.password = "" - if (u.username || u.password) { - state.credit_facade_credentials = { - type: "basic", - password: u.password, - username: u.username, + + async function testAccountInfo() { + const revenueAPI = !state.credit_facade_url + ? undefined + : new URL("./", state.credit_facade_url); + + if (revenueAPI) { + const resp = await testRevenueAPI( + revenueAPI, + state.credit_facade_credentials, + ); + if (resp instanceof TalerError) { + setTestError(i18n.str`The request to check the revenue API failed.`); + setState({ + ...state, + verified: undefined, + }); + return; + } else if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.BadRequest: { + setTestError(i18n.str`Server replied with "bad request".`); + setState({ + ...state, + verified: undefined, + }); + return; + } + case HttpStatusCode.Unauthorized: { + setTestError(i18n.str`Unauthorized, check credentials.`); + setState({ + ...state, + verified: undefined, + }); + return; + } + case HttpStatusCode.NotFound: { + setTestError( + i18n.str`The endpoint doesn't seems to be a Taler Revenue API.`, + ); + setState({ + ...state, + verified: undefined, + }); + return; } - state.repeatPassword = u.password + default: { + assertUnreachable(resp); + } + } + } else { + const found = resp.body; + const match = state.payto_uri === found; + setState({ + ...state, + verified: match, + }); + if (!match) { + setRevenuePayto(parsePaytoUri(resp.body)); } - u.password = "" - u.username = "" - state.credit_facade_url = u.href; - setImporting(false) - }} />} + setTestError(undefined); + } + } + } + + return ( + <Fragment> <section class="section is-main-section"> <div class="columns"> <div class="column" /> @@ -147,12 +210,20 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { > <InputPaytoForm<Entity> name="payto_uri" - label={i18n.str`Account`} + label={i18n.str`Account details`} /> + <div class="message-body" style={{ marginBottom: 10 }}> + <p> + <i18n.Translate> + If the bank supports Taler Revenue API then you can add the + endpoint URL below to keep the revenue information in sync. + </i18n.Translate> + </p> + </div> <Input<Entity> name="credit_facade_url" - label={i18n.str`Account info URL`} - help="https://bank.com" + label={i18n.str`Endpoint URL`} + help="https://bank.demo.taler.net/accounts/_username_/taler-revenue/" expand tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`} /> @@ -179,21 +250,43 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { 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} + <InputToggle<Entity> + label={i18n.str`Match`} + tooltip={i18n.str`Check where the information match against the server info.`} + name="verified" + readonly + threeState + help={ + testError !== undefined + ? testError + : state.verified === undefined + ? i18n.str`Not verified` + : state.verified + ? i18n.str`Last test was ok` + : i18n.str`Last test failed` + } + side={ + <button + class="button is-info" + data-tooltip={i18n.str`Compare info from server with account form`} + disabled={!state.credit_facade_url} + onClick={async () => { + const result = await testAccountInfo(); + }} + > + <i18n.Translate>Test</i18n.Translate> + </button> + } + /> </FormProvider> <div class="buttons is-right mt-5"> <button class="button is-info" - data-tooltip={i18n.str`Need to complete marked fields`} onClick={() => { - setImporting(true) + setImporting(true); }} > <i18n.Translate>Import from bank</i18n.Translate> @@ -220,6 +313,52 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <div class="column" /> </div> </section> - </div> + {!importing ? undefined : ( + <ImportingAccountModal + onCancel={() => { + setImporting(false); + }} + onConfirm={(ac) => { + const u = new URL(ac.infoURL); + const user = u.username; + const pwd = u.password; + u.password = ""; + u.username = ""; + const credit_facade_url = u.href; + setState({ + payto_uri: ac.accountURI, + credit_facade_credentials: + user || pwd + ? { + type: "basic", + password: pwd, + username: user, + } + : undefined, + credit_facade_url, + }); + setImporting(false); + }} + /> + )} + {!revenuePayto ? undefined : ( + <CompareAccountsModal + onCancel={() => { + setRevenuePayto(undefined); + }} + onConfirm={(d) => { + setState({ + ...state, + payto_uri: d, + }); + setRevenuePayto(undefined); + }} + formPayto={ + !state.payto_uri ? undefined : parsePaytoUri(state.payto_uri) + } + testPayto={revenuePayto} + /> + )} + </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx index aa1481a2e..6994c579c 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx @@ -33,7 +33,7 @@ import { } from "@gnu-taler/taler-util"; import { BrowserFetchHttpLib, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -52,6 +52,7 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { const { lib: api } = useSessionContext(); const { state } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); + const [tested, setTested] = useState(false); const { i18n } = useTranslationContext(); return ( @@ -60,71 +61,17 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { <CreatePage onBack={onBack} onCreate={async (request: Entity) => { - const revenueAPI = !request.credit_facade_url - ? undefined - : new URL("./", request.credit_facade_url); - - if (revenueAPI) { - const resp = await testRevenueAPI( - revenueAPI, - request.credit_facade_credentials, - request.payto_uri, - ); - if (resp instanceof TalerError) { - setNotif({ - message: i18n.str`Could not add bank account`, - type: "ERROR", - description: i18n.str`The request to check the revenue API failed.`, - details: JSON.stringify(resp.errorDetail, undefined, 2), - }); - return; - } - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.BadRequest: { - setNotif({ - message: i18n.str`Could not add bank account`, - type: "ERROR", - description: i18n.str`Server replied with "bad request".`, - }); - return; - - } - case HttpStatusCode.Unauthorized: { - setNotif({ - message: i18n.str`Could not add bank account`, - type: "ERROR", - description: i18n.str`Unauthorized, try with another credentials.`, - }); - return; - - } - case HttpStatusCode.NotFound: { - setNotif({ - message: i18n.str`Could not add bank account`, - type: "ERROR", - description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, - }); - return; - } - case TestRevenueErrorType.ANOTHER_ACCOUNT: { - setNotif({ - message: i18n.str`Could not add bank account`, - type: "ERROR", - description: i18n.str`The account info URL returned information from an account which is not the same in the account form: ${resp.detail.hint}`, - }); - return; - } - default: { - assertUnreachable(resp); - } - } - } - } - return api.instance .addBankAccount(state.token, request) - .then(() => { + .then((created) => { + if (created.type === "fail") { + setNotif({ + message: i18n.str`could not create account`, + type: "ERROR", + description: created.detail.hint, + }); + return; + } onConfirm(); }) .catch((error) => { @@ -147,12 +94,13 @@ export enum TestRevenueErrorType { export async function testRevenueAPI( revenueAPI: URL, creds: FacadeCredentials | undefined, - account: PaytoString, -): Promise<OperationOk<void> | OperationFail<HttpStatusCode.NotFound> -| OperationFail<HttpStatusCode.Unauthorized> -| OperationFail<HttpStatusCode.BadRequest> -| OperationFail<TestRevenueErrorType.ANOTHER_ACCOUNT> -| TalerError> { +): Promise< + | OperationOk<PaytoString> + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.Unauthorized> + | OperationFail<HttpStatusCode.BadRequest> + | TalerError +> { const api = new TalerRevenueHttpClient( revenueAPI.href, new BrowserFetchHttpLib(), @@ -176,21 +124,12 @@ export async function testRevenueAPI( return config; } - const history = await api.getHistory(auth); - - if (history.type === "fail") { - return history; - } - if (history.body.credit_account !== account) { - return { - type: "fail", - case: TestRevenueErrorType.ANOTHER_ACCOUNT, - detail: { - code: 1, - hint: history.body.credit_account - }, - }; + const resp = await api.getHistory(auth); + if (resp.type === "fail") { + return resp; } + + return opFixedSuccess(resp.body.credit_account as PaytoString); } catch (err) { if (err instanceof TalerError) { return err; @@ -200,7 +139,6 @@ export async function testRevenueAPI( // detail: err.errorDetail, // }; } + throw err; } - - return opFixedSuccess(undefined); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx index 4ee68cd80..7e0b89f39 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx @@ -24,12 +24,12 @@ import { h, VNode } from "preact"; import { CardTable } from "./Table.js"; export interface Props { - devices: TalerMerchantApi.BankAccountSummaryEntry[]; + devices: TalerMerchantApi.BankAccountEntry[]; // onLoadMoreBefore?: () => void; // onLoadMoreAfter?: () => void; onCreate: () => void; - onDelete: (e: TalerMerchantApi.BankAccountSummaryEntry) => void; - onSelect: (e: TalerMerchantApi.BankAccountSummaryEntry) => void; + onDelete: (e: TalerMerchantApi.BankAccountEntry) => void; + onSelect: (e: TalerMerchantApi.BankAccountEntry) => void; } export function ListPage({ @@ -40,7 +40,6 @@ export function ListPage({ // onLoadMoreBefore, // onLoadMoreAfter, }: Props): VNode { - return ( <section class="section is-main-section"> <CardTable diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx index a9cb2805b..0e813f4d2 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx @@ -19,12 +19,21 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { parsePaytoUri, PaytoType, PaytoUri, PaytoUriBitcoin, PaytoUriIBAN, PaytoUriTalerBank, PaytoUriUnknown, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + parsePaytoUri, + PaytoType, + PaytoUri, + PaytoUriBitcoin, + PaytoUriIBAN, + PaytoUriTalerBank, + PaytoUriUnknown, + TalerMerchantApi, +} from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { StateUpdater, useState } from "preact/hooks"; -type Entity = TalerMerchantApi.BankAccountSummaryEntry; +type Entity = TalerMerchantApi.BankAccountEntry; interface Props { accounts: Entity[]; @@ -93,241 +102,255 @@ interface TableProps { rowSelectionHandler: StateUpdater<string[]>; } -function Table({ - accounts, - onDelete, - onSelect, -}: TableProps): VNode { +function Table({ accounts, onDelete, onSelect }: TableProps): VNode { const { i18n } = useTranslationContext(); - const emptyList: Record<PaytoType | "unknown", { parsed: PaytoUri, acc: Entity }[]> = { "bitcoin": [], "x-taler-bank": [], "iban": [], "unknown": [], } + 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 }) + 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 }) + 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 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> + )} - {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>} + {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 /> - </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 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 /> + </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 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>} + {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> - ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx index 1eda7382d..9d09473bc 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx @@ -19,10 +19,13 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util"; import { - useTranslationContext -} from "@gnu-taler/web-util/browser"; + HttpStatusCode, + TalerError, + TalerMerchantApi, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; @@ -40,30 +43,27 @@ interface Props { onSelect: (id: string) => void; } -export default function ListOtpDevices({ - onCreate, - onSelect, -}: Props): VNode { +export default function ListOtpDevices({ onCreate, onSelect }: Props): VNode { const { i18n } = useTranslationContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { lib: api } = useSessionContext(); const { state } = useSessionContext(); const result = useInstanceBankAccounts(); - if (!result) return <Loading /> + if (!result) return <Loading />; if (result instanceof TalerError) { - return <ErrorLoadingMerchant error={result} /> + return <ErrorLoadingMerchant error={result} />; } if (result.type === "fail") { - switch(result.case) { + switch (result.case) { case HttpStatusCode.NotFound: { - return <NotFoundPageOrAdminCreate /> + return <NotFoundPageOrAdminCreate />; } case HttpStatusCode.Unauthorized: { - return <LoginPage /> + return <LoginPage />; } default: { - assertUnreachable(result) + assertUnreachable(result); } } } @@ -71,13 +71,15 @@ export default function ListOtpDevices({ return ( <Fragment> <NotificationCard notification={notif} /> - {result.body.accounts.length < 1 && - <NotificationCard notification={{ - type: "WARN", - message: i18n.str`You need to associate a bank account to receive revenue.`, - description: i18n.str`Without this the merchant backend will refuse to create new orders.` - }} /> - } + {result.body.accounts.length < 1 && ( + <NotificationCard + notification={{ + type: "WARN", + message: i18n.str`You need to associate a bank account to receive revenue.`, + description: i18n.str`Without this the you won't be able to create new orders.`, + }} + /> + )} <ListPage devices={result.body.accounts} // onLoadMoreBefore={ @@ -88,8 +90,9 @@ export default function ListOtpDevices({ onSelect={(e) => { onSelect(e.h_wire); }} - onDelete={(e: TalerMerchantApi.BankAccountSummaryEntry) => { - return api.instance.deleteBankAccount(state.token, e.h_wire) + onDelete={(e: TalerMerchantApi.BankAccountEntry) => { + return api.instance + .deleteBankAccount(state.token, e.h_wire) .then(() => setNotif({ message: i18n.str`bank account delete successfully`, @@ -102,9 +105,8 @@ export default function ListOtpDevices({ type: "ERROR", description: error.message, }), - ) - } - } + ); + }} /> </Fragment> ); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx index 1a8e9bdc1..73fe43026 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx @@ -19,9 +19,18 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + HttpStatusCode, + PaytoString, + PaytoUri, + TalerError, + TalerMerchantApi, + TranslatedString, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; 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 { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { @@ -31,32 +40,64 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { WithId } from "../../../../declaration.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; +import { testRevenueAPI } from "../create/index.js"; +import { InputToggle } from "../../../../components/form/InputToggle.js"; +import { + CompareAccountsModal, + ImportingAccountModal, +} from "../../../../components/modal/index.js"; -type Entity = TalerMerchantApi.BankAccountEntry & WithId; - +type Entity = TalerMerchantApi.BankAccountDetail & WithId; +type FormType = TalerMerchantApi.AccountPatchDetails & { + verified: boolean; + payto_uri?: PaytoString; +}; const accountAuthType = ["unedit", "none", "basic"]; interface Props { onUpdate: (d: TalerMerchantApi.AccountPatchDetails) => Promise<void>; + onReplace: ( + prev: TalerMerchantApi.BankAccountDetail, + next: TalerMerchantApi.AccountAddDetails, + ) => Promise<void>; onBack?: () => void; account: Entity; } -export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { +export function UpdatePage({ + account, + onUpdate, + onBack, + onReplace, +}: Props): VNode { const { i18n } = useTranslationContext(); - const [state, setState] = - useState<Partial<TalerMerchantApi.AccountPatchDetails>>(account); + const [state, setState] = useState<Partial<FormType>>({ + payto_uri: account.payto_uri, + credit_facade_url: account.credit_facade_url, + credit_facade_credentials: { + // @ts-ignore + type: "unedit", + }, + }); + const [importing, setImporting] = useState(false); - // @ts-expect-error "unedit" is fine since is part of the accountAuthType values - if (state.credit_facade_credentials?.type === "unedit") { - // we use this to set creds to undefined but server don't get this type - state.credit_facade_credentials = undefined; - } + const [revenuePayto, setRevenuePayto] = useState<PaytoUri | undefined>( + // parsePaytoUri("payto://x-taler-bank/asd.com:1010/asd/pepe"), + undefined, + ); + const [testError, setTestError] = useState<TranslatedString | undefined>( + undefined, + ); + + const replacingAccountId = state.payto_uri !== account.payto_uri; const facadeURL = safeConvertURL(state.credit_facade_url); - const errors: FormErrors<TalerMerchantApi.AccountPatchDetails> = { + const errors: FormErrors<FormType> = { + payto_uri: !state.payto_uri ? i18n.str`required` : undefined, + credit_facade_url: !state.credit_facade_url ? undefined : !facadeURL @@ -67,32 +108,30 @@ export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { ? i18n.str`URL should not contain params` : facadeURL.hash ? i18n.str`URL should not hash param` + : undefined, + credit_facade_credentials: !state.credit_facade_credentials + ? undefined + : undefinedIfEmpty({ + type: + replacingAccountId && + // @ts-ignore + state.credit_facade_credentials?.type === "unedit" + ? i18n.str`required` + : undefined, + username: + state.credit_facade_credentials?.type !== "basic" + ? undefined + : !state.credit_facade_credentials.username + ? i18n.str`required` : undefined, - credit_facade_credentials: undefinedIfEmpty({ - username: - state.credit_facade_credentials?.type !== "basic" - ? undefined - : !state.credit_facade_credentials.username - ? i18n.str`required` - : undefined, - password: - state.credit_facade_credentials?.type !== "basic" - ? undefined - : !state.credit_facade_credentials.password - ? i18n.str`required` - : undefined, - - repeatPassword: - state.credit_facade_credentials?.type !== "basic" - ? undefined - : !(state.credit_facade_credentials as any).repeatPassword - ? i18n.str`required` - : (state.credit_facade_credentials as any).repeatPassword !== - state.credit_facade_credentials.password - ? i18n.str`doesn't match` - : undefined, - }), + password: + state.credit_facade_credentials?.type !== "basic" + ? undefined + : !state.credit_facade_credentials.password + ? i18n.str`required` + : undefined, + }), }; const hasErrors = Object.keys(errors).some( @@ -111,21 +150,98 @@ export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { credit_facade_url == undefined || state.credit_facade_credentials === undefined ? undefined - : state.credit_facade_credentials.type === "basic" - ? { - type: "basic", - password: state.credit_facade_credentials.password, - username: state.credit_facade_credentials.username, - } - : { - type: "none", - }; - - return onUpdate({ credit_facade_credentials, credit_facade_url }); + : // @ts-ignore + state.credit_facade_credentials.type === "unedit" + ? undefined + : state.credit_facade_credentials.type === "basic" + ? { + type: "basic", + password: state.credit_facade_credentials.password, + username: state.credit_facade_credentials.username, + } + : { + type: "none", + }; + + if (replacingAccountId) { + console.log("======== REPLACE"); + return onReplace(account, { + payto_uri: state.payto_uri!, + credit_facade_credentials, + credit_facade_url, + }); + } else { + console.log("======== UPDATE"); + return onUpdate({ credit_facade_credentials, credit_facade_url }); + } }; + async function testAccountInfo() { + const revenueAPI = !state.credit_facade_url + ? undefined + : new URL("./", state.credit_facade_url); + + if (revenueAPI) { + const resp = await testRevenueAPI( + revenueAPI, + state.credit_facade_credentials, + ); + if (resp instanceof TalerError) { + setTestError(i18n.str`The request to check the revenue API failed.`); + setState({ + ...state, + verified: undefined, + }); + return; + } else if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.BadRequest: { + setTestError(i18n.str`Server replied with "bad request".`); + setState({ + ...state, + verified: undefined, + }); + return; + } + case HttpStatusCode.Unauthorized: { + setTestError(i18n.str`Unauthorized, check credentials.`); + setState({ + ...state, + verified: false, + }); + return; + } + case HttpStatusCode.NotFound: { + setTestError( + i18n.str`The endpoint doesn't seems to be a Taler Revenue API.`, + ); + setState({ + ...state, + verified: undefined, + }); + return; + } + default: { + assertUnreachable(resp); + } + } + } else { + const found = resp.body; + const match = state.payto_uri === found; + setState({ + ...state, + verified: match, + }); + if (!match) { + setRevenuePayto(parsePaytoUri(resp.body)); + } + setTestError(undefined); + } + } + } + return ( - <div> + <Fragment> <section class="section"> <section class="hero is-hero-bar"> <div class="hero-body"> @@ -133,7 +249,8 @@ export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { <div class="level-left"> <div class="level-item"> <span class="is-size-4"> - Account: <b>{account.id.substring(0, 8)}...</b> + <i18n.Translate>Account:</i18n.Translate>{" "} + <b>{account.id.substring(0, 8)}...</b> </span> </div> </div> @@ -150,15 +267,23 @@ export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { valueHandler={setState} errors={errors} > - <InputPaytoForm<Entity> + <InputPaytoForm<FormType> name="payto_uri" label={i18n.str`Account`} - readonly /> + <div class="message-body" style={{ marginBottom: 10 }}> + <p> + <i18n.Translate> + If the bank supports Taler Revenue API then you can add + the endpoint URL below to keep the revenue information in + sync. + </i18n.Translate> + </p> + </div> <Input<Entity> name="credit_facade_url" - label={i18n.str`Account info URL`} - help="https://bank.com" + label={i18n.str`Endpoint URL`} + help="https://bank.demo.taler.net/accounts/_username_/taler-revenue/" expand tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`} /> @@ -186,13 +311,36 @@ export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { label={i18n.str`Password`} tooltip={i18n.str`Password to access the account information.`} /> - <Input - name="credit_facade_credentials.repeatPassword" - inputType="password" - label={i18n.str`Repeat password`} - /> </Fragment> ) : undefined} + <InputToggle<FormType> + label={i18n.str`Match`} + tooltip={i18n.str`Check where the information match against the server info.`} + name="verified" + readonly + threeState + help={ + testError !== undefined + ? testError + : state.verified === undefined + ? i18n.str`Not verified` + : state.verified + ? i18n.str`Last test was ok` + : i18n.str`Last test failed` + } + side={ + <button + class="button is-info" + data-tooltip={i18n.str`Compare info from server with account form`} + disabled={!state.credit_facade_url} + onClick={async () => { + const result = await testAccountInfo(); + }} + > + <i18n.Translate>Test</i18n.Translate> + </button> + } + /> </FormProvider> <div class="buttons is-right mt-5"> @@ -217,7 +365,53 @@ export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { </div> </section> </section> - </div> + {!importing ? undefined : ( + <ImportingAccountModal + onCancel={() => { + setImporting(false); + }} + onConfirm={(ac) => { + const u = new URL(ac.infoURL); + const user = u.username; + const pwd = u.password; + u.password = ""; + u.username = ""; + const credit_facade_url = u.href; + setState({ + payto_uri: ac.accountURI, + credit_facade_credentials: + user || pwd + ? { + type: "basic", + password: pwd, + username: user, + } + : undefined, + credit_facade_url, + }); + setImporting(false); + }} + /> + )} + {!revenuePayto ? undefined : ( + <CompareAccountsModal + onCancel={() => { + setRevenuePayto(undefined); + }} + onConfirm={(d) => { + setState({ + ...state, + payto_uri: d, + }); + setRevenuePayto(undefined); + }} + formPayto={ + !state.payto_uri ? undefined : parsePaytoUri(state.payto_uri) + } + testPayto={revenuePayto} + /> + )} + </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx index 9116aaa62..60dad7257 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx @@ -19,10 +19,13 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util"; import { - useTranslationContext -} from "@gnu-taler/web-util/browser"; + HttpStatusCode, + TalerError, + TalerMerchantApi, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; @@ -33,8 +36,8 @@ import { useBankAccountDetails } from "../../../../hooks/bank.js"; import { Notification } from "../../../../utils/types.js"; import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; -import { TestRevenueErrorType, testRevenueAPI } from "../create/index.js"; import { UpdatePage } from "./UpdatePage.js"; +import { WithId } from "../../../../declaration.js"; export type Entity = TalerMerchantApi.AccountPatchDetails & WithId; @@ -65,7 +68,7 @@ export default function UpdateValidator({ return <NotFoundPageOrAdminCreate />; } case HttpStatusCode.Unauthorized: { - return <LoginPage /> + return <LoginPage />; } default: { assertUnreachable(result); @@ -80,76 +83,71 @@ export default function UpdateValidator({ account={{ ...result.body, id: bid }} onBack={onBack} onUpdate={async (request) => { - const revenueAPI = !request.credit_facade_url - ? undefined - : new URL("./", request.credit_facade_url); - - if (revenueAPI) { - const resp = await testRevenueAPI( - revenueAPI, - request.credit_facade_credentials, - result.body.payto_uri, + return api.instance + .updateBankAccount(state.token, bid, request) + .then((updated) => { + if (updated.type === "fail") { + setNotif({ + message: i18n.str`could not update account`, + type: "ERROR", + description: updated.detail.hint, + }); + return; + } + onConfirm(); + }) + .catch((error) => { + setNotif({ + message: i18n.str`could not update account`, + type: "ERROR", + description: error.message, + }); + }); + }} + onReplace={async (prev, next) => { + try { + const created = await api.instance.addBankAccount( + state.token, + next, ); - if (resp instanceof TalerError) { + if (created.type === "fail") { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`could not create account`, type: "ERROR", - description: i18n.str`The request to check the revenue API failed.`, - details: JSON.stringify(resp.errorDetail, undefined, 2), + description: created.detail.hint, }); return; } - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.BadRequest: { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: i18n.str`Server replied with "bad request".`, - }); - return; - - } - case HttpStatusCode.Unauthorized: { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: i18n.str`Unauthorized, try with another credentials.`, - }); - return; - - } - case HttpStatusCode.NotFound: { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, - }); - return; - } - case TestRevenueErrorType.ANOTHER_ACCOUNT: { - setNotif({ - message: i18n.str`Could not add bank account`, - type: "ERROR", - description: i18n.str`The account info URL returned information from an account which is not the same in the account form: ${resp.detail.hint}`, - }); - return; - } - default: { - assertUnreachable(resp); - } - } - } + } catch (error: any) { + setNotif({ + message: i18n.str`could not create account`, + type: "ERROR", + description: error.message, + }); + return; } - return api.instance.updateBankAccount(state.token, bid, request) - .then(onConfirm) - .catch((error) => { + try { + const deleted = await api.instance.deleteBankAccount( + state.token, + prev.h_wire, + ); + if (deleted.type === "fail") { setNotif({ - message: i18n.str`could not update account`, + message: i18n.str`could not delete account`, type: "ERROR", - description: error.message, + description: deleted.detail.hint, }); + return; + } + } catch (error: any) { + setNotif({ + message: i18n.str`could not delete account`, + type: "ERROR", + description: error.message, }); + return; + } + onConfirm(); }} /> </Fragment> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx index 7be3d23f6..151905b5e 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx @@ -52,6 +52,7 @@ import { useSessionContext } from "../../../../context/session.js"; import { usePreference } from "../../../../hooks/preference.js"; import { rate } from "../../../../utils/amount.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; +import { WithId } from "../../../../declaration.js"; interface Props { onCreate: (d: TalerMerchantApi.PostOrderRequest) => void; @@ -138,7 +139,7 @@ export function CreatePage({ const instance_default = with_defaults(instanceConfig, config.currency); const [value, valueHandler] = useState(instance_default); const zero = Amounts.zeroOfCurrency(config.currency); - const [settings, updateSettings] = usePreference(); + const [pref, updatePrefs] = usePreference(); const inventoryList = Object.values(value.inventoryProducts || {}); const productList = Object.values(value.products || {}); @@ -360,9 +361,9 @@ export function CreatePage({ <div class="tabs is-toggle is-fullwidth is-small"> <ul> <li - class={!settings.advanceOrderMode ? "is-active" : ""} + class={!pref.advanceOrderMode ? "is-active" : ""} onClick={() => { - updateSettings("advanceOrderMode", false); + updatePrefs("advanceOrderMode", false); }} > <a> @@ -372,9 +373,9 @@ export function CreatePage({ </a> </li> <li - class={settings.advanceOrderMode ? "is-active" : ""} + class={pref.advanceOrderMode ? "is-active" : ""} onClick={() => { - updateSettings("advanceOrderMode", true); + updatePrefs("advanceOrderMode", true); }} > <a> @@ -408,7 +409,7 @@ export function CreatePage({ inventory={instanceInventory} /> - {settings.advanceOrderMode && ( + {pref.advanceOrderMode && ( <NonInventoryProductFrom productToEdit={editingProduct} onAddProduct={(p) => { @@ -481,7 +482,7 @@ export function CreatePage({ tooltip={i18n.str`Title of the order to be shown to the customer`} /> - {settings.advanceOrderMode && ( + {pref.advanceOrderMode && ( <InputGroup name="shipping" label={i18n.str`Shipping and Fulfillment`} @@ -509,13 +510,13 @@ export function CreatePage({ </InputGroup> )} - {(settings.advanceOrderMode || requiresSomeTalerOptions) && ( + {(pref.advanceOrderMode || requiresSomeTalerOptions) && ( <InputGroup name="payments" label={i18n.str`Taler payment options`} tooltip={i18n.str`Override default Taler payment settings for this order`} > - {(settings.advanceOrderMode || noDefault_payDeadline) && ( + {(pref.advanceOrderMode || noDefault_payDeadline) && ( <InputDuration name="payments.pay_deadline" label={i18n.str`Payment time`} @@ -547,7 +548,7 @@ export function CreatePage({ } /> )} - {settings.advanceOrderMode && ( + {pref.advanceOrderMode && ( <InputDuration name="payments.refund_deadline" label={i18n.str`Refund time`} @@ -580,7 +581,7 @@ export function CreatePage({ } /> )} - {(settings.advanceOrderMode || noDefault_wireDeadline) && ( + {(pref.advanceOrderMode || noDefault_wireDeadline) && ( <InputDuration name="payments.wire_transfer_deadline" label={i18n.str`Wire transfer time`} @@ -614,7 +615,7 @@ export function CreatePage({ } /> )} - {settings.advanceOrderMode && ( + {pref.advanceOrderMode && ( <InputDuration name="payments.auto_refund_deadline" label={i18n.str`Auto-refund time`} @@ -628,21 +629,21 @@ export function CreatePage({ /> )} - {settings.advanceOrderMode && ( + {pref.advanceOrderMode && ( <InputCurrency name="payments.max_fee" label={i18n.str`Maximum fee`} 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.`} /> )} - {settings.advanceOrderMode && ( + {pref.advanceOrderMode && ( <InputToggle name="payments.createToken" label={i18n.str`Create token`} tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`} /> )} - {settings.advanceOrderMode && ( + {pref.advanceOrderMode && ( <InputNumber name="payments.minimum_age" label={i18n.str`Minimum age required`} @@ -657,7 +658,7 @@ export function CreatePage({ </InputGroup> )} - {settings.advanceOrderMode && ( + {pref.advanceOrderMode && ( <InputGroup name="extra" label={i18n.str`Additional information`} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx index 861114014..04f0b2482 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx @@ -19,7 +19,12 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util"; +import { + HttpStatusCode, + TalerError, + TalerMerchantApi, + assertUnreachable, +} from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; @@ -41,24 +46,21 @@ interface Props { onBack?: () => void; onConfirm: (id: string) => void; } -export default function OrderCreate({ - onConfirm, - onBack, -}: Props): VNode { +export default function OrderCreate({ onConfirm, onBack }: Props): VNode { const { lib } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { state } = useSessionContext(); const detailsResult = useInstanceDetails(); const inventoryResult = useInstanceProducts(); - if (!detailsResult) return <Loading /> + if (!detailsResult) return <Loading />; if (detailsResult instanceof TalerError) { - return <ErrorLoadingMerchant error={detailsResult} /> + return <ErrorLoadingMerchant error={detailsResult} />; } if (detailsResult.type === "fail") { switch (detailsResult.case) { case HttpStatusCode.Unauthorized: { - return <LoginPage /> + return <LoginPage />; } case HttpStatusCode.NotFound: { return <NotFoundPageOrAdminCreate />; @@ -68,9 +70,9 @@ export default function OrderCreate({ } } } - if (!inventoryResult) return <Loading /> + if (!inventoryResult) return <Loading />; if (inventoryResult instanceof TalerError) { - return <ErrorLoadingMerchant error={inventoryResult} /> + return <ErrorLoadingMerchant error={inventoryResult} />; } if (inventoryResult.type === "fail") { switch (inventoryResult.case) { @@ -78,7 +80,7 @@ export default function OrderCreate({ return <NotFoundPageOrAdminCreate />; } case HttpStatusCode.Unauthorized: { - return <LoginPage /> + return <LoginPage />; } default: { assertUnreachable(inventoryResult); @@ -93,10 +95,11 @@ export default function OrderCreate({ <CreatePage onBack={onBack} onCreate={(request: TalerMerchantApi.PostOrderRequest) => { - lib.instance.createOrder(state.token, request) + lib.instance + .createOrder(state.token, request) .then((r) => { if (r.type === "ok") { - return onConfirm(r.body.order_id) + return onConfirm(r.body.order_id); } else { setNotif({ message: "could not create order", diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx index 408bc0c0a..4681f9943 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx @@ -27,6 +27,7 @@ import { useState } from "preact/hooks"; import { DatePicker } from "../../../../components/picker/DatePicker.js"; import { dateFormatForSettings, usePreference } from "../../../../hooks/preference.js"; import { CardTable } from "./Table.js"; +import { WithId } from "../../../../declaration.js"; export interface ListPageProps { onShowAll: () => void; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx index 5ece34409..7b9691fdd 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx @@ -41,6 +41,7 @@ import { usePreference, } from "../../../../hooks/preference.js"; import { mergeRefunds } from "../../../../utils/amount.js"; +import { WithId } from "../../../../declaration.js"; type Entity = TalerMerchantApi.OrderHistoryEntry & WithId; interface Props { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx index 35d67cbc6..7866b9cd9 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx @@ -31,6 +31,7 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; +import { WithId } from "../../../../declaration.js"; type Entity = TalerMerchantApi.OtpDevicePatchDetails & WithId; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx index 99edb95c3..2fe3abaae 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx @@ -40,6 +40,7 @@ import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { CreatedSuccessfully } from "../create/CreatedSuccessfully.js"; import { UpdatePage } from "./UpdatePage.js"; +import { WithId } from "../../../../declaration.js"; export type Entity = TalerMerchantApi.OtpDevicePatchDetails & WithId; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx index 39e2fd0c7..08d42a8c9 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx @@ -32,6 +32,7 @@ import { import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputNumber } from "../../../../components/form/InputNumber.js"; import { dateFormatForSettings, usePreference } from "../../../../hooks/preference.js"; +import { WithId } from "../../../../declaration.js"; type Entity = TalerMerchantApi.ProductDetail & WithId; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx index 6ad0d4598..dc3e73850 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx @@ -38,6 +38,7 @@ import { Notification } from "../../../../utils/types.js"; import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { CardTable } from "./Table.js"; +import { WithId } from "../../../../declaration.js"; interface Props { onCreate: () => void; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx index 50262be17..336a336ed 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -145,7 +145,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { template_id: state.id!, template_description: state.description!, template_contract, - required_currency: contract_amount !== undefined ? undefined : config.currency, editable_defaults: { amount: !state.amount_editable ? undefined : (state.amount ?? zero), summary: !state.summary_editable ? undefined : (state.summary ?? ""), diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx index fce14dcc3..4fe11bf5c 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx @@ -54,11 +54,10 @@ export default function ListTemplates({ }: Props): VNode { const { i18n } = useTranslationContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { lib } = useSessionContext(); + const { state, lib } = useSessionContext(); const result = useInstanceTemplates(); const [deleting, setDeleting] = useState<TalerMerchantApi.TemplateEntry | null>(null); - const { state } = useSessionContext(); if (!result) return <Loading /> if (result instanceof TalerError) { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx index 32c5637aa..113cf5baa 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx @@ -46,6 +46,7 @@ import { InputToggle } from "../../../../components/form/InputToggle.js"; import { TextField } from "../../../../components/form/TextField.js"; import { useSessionContext } from "../../../../context/session.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; +import { WithId } from "../../../../declaration.js"; type Entity = { description?: string; @@ -161,7 +162,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { return onUpdate({ template_description: state.description!, template_contract, - required_currency: contract_amount !== undefined ? undefined : config.currency, editable_defaults: { amount: !state.amount_editable ? undefined : (state.amount ?? zero), summary: !state.summary_editable ? undefined : (state.summary ?? ""), diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx index 6185bd2a9..ad804831c 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx @@ -36,6 +36,7 @@ import { Notification } from "../../../../utils/types.js"; import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { UpdatePage } from "./UpdatePage.js"; +import { WithId } from "../../../../declaration.js"; export type Entity = TalerMerchantApi.TemplatePatchDetails & WithId; diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/create/Create.stories.tsx index 2fc0819bb..82038c918 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/create/Create.stories.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,14 +16,14 @@ /** * - * @author Sebastian Javier Marchano (sebasjm) + * @author Christian Blättler */ import { h, VNode, FunctionalComponent } from "preact"; import { CreatePage as TestedComponent } from "./CreatePage.js"; export default { - title: "Pages/Product/Create", + title: "Pages/TokenFamily/Create", component: TestedComponent, argTypes: { onCreate: { action: "onCreate" }, diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/create/CreatePage.tsx index becaf8f3a..cab5ba9cf 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/create/CreatePage.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,19 +16,17 @@ /** * - * @author Sebastian Javier Marchano (sebasjm) + * @author Christian Blättler */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; -import { ProductForm } from "../../../../components/product/ProductForm.js"; -import { MerchantBackend } from "../../../../declaration.js"; import { useListener } from "../../../../hooks/listener.js"; +import { TokenFamilyForm } from "../../../../components/tokenfamily/TokenFamilyForm.js"; +import { TalerMerchantApi } from "@gnu-taler/taler-util"; -type Entity = MerchantBackend.Products.ProductAddDetail & { - product_id: string; -}; +type Entity = TalerMerchantApi.TokenFamilyCreateRequest; interface Props { onCreate: (d: Entity) => Promise<void>; @@ -51,7 +49,9 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> - <ProductForm onSubscribe={addFormSubmitter} /> + <TokenFamilyForm onSubscribe={addFormSubmitter} /> + + {/* <Test /> */} <div class="buttons is-right mt-5"> {onBack && ( diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/create/index.tsx index 924e6d9b8..32c92cab0 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/create/index.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,7 +16,7 @@ /** * - * @author Sebastian Javier Marchano (sebasjm) + * @author Christian Blättler */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -24,38 +24,38 @@ 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 { useSessionContext } from "../../../../context/session.js"; import { CreatePage } from "./CreatePage.js"; -export type Entity = MerchantBackend.Webhooks.WebhookAddDetails; +export type Entity = MerchantBackend.TokenFamilies.TokenFamilyAddDetail; interface Props { onBack?: () => void; onConfirm: () => void; } - -export default function CreateWebhook({ onConfirm, onBack }: Props): VNode { - const { createWebhook } = useWebhookAPI(); +export default function CreateTokenFamily({ onConfirm, onBack }: Props): VNode { const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); + const { lib } = useSessionContext(); + const { state } = useSessionContext(); return ( - <> + <Fragment> <NotificationCard notification={notif} /> <CreatePage onBack={onBack} - onCreate={(request: MerchantBackend.Webhooks.WebhookAddDetails) => { - return createWebhook(request) + onCreate={(request) => { + return lib.instance.createTokenFamily(state.token, request) .then(() => onConfirm()) .catch((error) => { setNotif({ - message: i18n.str`could not inform template`, + message: i18n.str`could not create token family`, type: "ERROR", description: error.message, }); }); }} /> - </> + </Fragment> ); } diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/list/Table.tsx index 2c97b59e8..1af8a1192 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/list/Table.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,33 +16,25 @@ /** * - * @author Sebastian Javier Marchano (sebasjm) + * @author Christian Blättler */ -import { Amounts } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { ComponentChildren, Fragment, h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { StateUpdater, useState } from "preact/hooks"; -import emptyImage from "../../../../assets/empty.png"; -import { - FormErrors, - FormProvider, -} from "../../../../components/form/FormProvider.js"; -import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { AuditorBackend, WithId } from "../../../../declaration.js"; -import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; +import { format } from "date-fns"; +import { MerchantBackend } from "../../../../declaration.js"; +import { TalerMerchantApi } from "@gnu-taler/taler-util"; -type Entity = AuditorBackend.DepositConfirmation.DepositConfirmationDetail & WithId; +type Entity = TalerMerchantApi.TokenFamilySummary; interface Props { instances: Entity[]; - onDelete: (id: Entity) => void; - onSelect: (depositConfirmation: Entity) => void; + onDelete: (tokenFamily: Entity) => void; + onSelect: (tokenFamily: Entity) => void; onUpdate: ( - id: string, - data: AuditorBackend.DepositConfirmation.DepositConfirmationDetail, + slug: string, + data: MerchantBackend.TokenFamilies.TokenFamilyPatchDetail, ) => Promise<void>; onCreate: () => void; selected?: boolean; @@ -66,12 +58,12 @@ export function CardTable({ <span class="icon"> <i class="mdi mdi-shopping" /> </span> - <i18n.Translate>Deposit Confirmations</i18n.Translate> + <i18n.Translate>Token Families</i18n.Translate> </p> <div class="card-header-icon" aria-label="more options"> <span class="has-tooltip-left" - data-tooltip={i18n.str`add deposit-confirmation`} + data-tooltip={i18n.str`add token family`} > <button class="button is-info" type="button" onClick={onCreate}> <span class="icon is-small"> @@ -105,12 +97,12 @@ export function CardTable({ interface TableProps { rowSelection: string | undefined; instances: Entity[]; - onSelect: (id: Entity) => void; + onSelect: (tokenFamily: Entity) => void; onUpdate: ( - id: string, - data: AuditorBackend.DepositConfirmation.DepositConfirmationDetail, + slug: string, + data: MerchantBackend.TokenFamilies.TokenFamilyPatchDetail, ) => Promise<void>; - onDelete: (serial_id: Entity) => void; + onDelete: (tokenFamily: Entity) => void; rowSelectionHandler: StateUpdater<string | undefined>; } @@ -123,55 +115,83 @@ function Table({ onDelete, }: TableProps): VNode { const { i18n } = useTranslationContext(); - const [settings] = useSettings(); return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> <thead> <tr> <th> - <i18n.Translate>Image</i18n.Translate> - </th> - <th> - <i18n.Translate>Description</i18n.Translate> - </th> - <th> - <i18n.Translate>Price per unit</i18n.Translate> + <i18n.Translate>Slug</i18n.Translate> </th> <th> - <i18n.Translate>Taxes</i18n.Translate> + <i18n.Translate>Name</i18n.Translate> </th> <th> - <i18n.Translate>Sales</i18n.Translate> + <i18n.Translate>Valid After</i18n.Translate> </th> <th> - <i18n.Translate>Stock</i18n.Translate> + <i18n.Translate>Valid Before</i18n.Translate> </th> <th> - <i18n.Translate>Sold</i18n.Translate> + <i18n.Translate>Kind</i18n.Translate> </th> <th /> </tr> </thead> <tbody> {instances.map((i) => { - return ( - <Fragment key={i.id}> + <Fragment key={i.slug}> <tr key="info"> <td onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + rowSelection !== i.slug && rowSelectionHandler(i.slug) } style={{ cursor: "pointer" }} > + {i.slug} + </td> + <td + onClick={() => + rowSelection !== i.slug && rowSelectionHandler(i.slug) + } + style={{ cursor: "pointer" }} + > + {i.name} + </td> + <td + onClick={() => + rowSelection !== i.slug && rowSelectionHandler(i.slug) + } + style={{ cursor: "pointer" }} + > + {i.valid_after.t_s === "never" + ? "never" + : format(new Date(i.valid_after.t_s * 1000), "yyyy/MM/dd hh:mm:ss")} + </td> + <td + onClick={() => + rowSelection !== i.slug && rowSelectionHandler(i.slug) + } + style={{ cursor: "pointer" }} + > + {i.valid_before.t_s === "never" + ? "never" + : format(new Date(i.valid_before.t_s * 1000), "yyyy/MM/dd hh:mm:ss")} + </td> + <td + onClick={() => + rowSelection !== i.slug && rowSelectionHandler(i.slug) + } + style={{ cursor: "pointer" }} + > + {i.kind} </td> - <td class="is-actions-cell right-sticky"> <div class="buttons is-right"> <span class="has-tooltip-bottom" - data-tooltip={i18n.str`go to product update page`} + data-tooltip={i18n.str`go to token family update page`} > <button class="button is-small is-success " @@ -183,7 +203,7 @@ function Table({ </span> <span class="has-tooltip-left" - data-tooltip={i18n.str`remove this product from the database`} + data-tooltip={i18n.str`remove this token family from the database`} > <button class="button is-small is-danger" @@ -196,12 +216,6 @@ function Table({ </div> </td> </tr> - {rowSelection === i.id && ( - <tr key="form"> - <td colSpan={10}> - </td> - </tr> - )} </Fragment> ); })} @@ -211,16 +225,6 @@ function Table({ ); } -interface FastProductUpdate { - incoming: number; - lost: number; - price: string; -} -interface UpdatePrice { - price: string; -} - - function EmptyTable(): VNode { const { i18n } = useTranslationContext(); @@ -233,17 +237,9 @@ function EmptyTable(): VNode { </p> <p> <i18n.Translate> - There is no products yet, add more pressing the + sign + There are no token families yet, add the first one by pressing the + sign. </i18n.Translate> </p> </div> ); } - -function difference(price: string, tax: number) { - if (!tax) return price; - const ps = price.split(":"); - const p = parseInt(ps[1], 10); - ps[1] = `${p - tax}`; - return ps.join(":"); -}
\ No newline at end of file diff --git a/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/list/index.tsx new file mode 100644 index 000000000..58d071ffc --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/list/index.tsx @@ -0,0 +1,143 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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 Christian Blättler + */ + +import { + ErrorType, + HttpError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } 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 { + useInstanceTokenFamilies, +} from "../../../../hooks/tokenfamily.js"; +import { Notification } from "../../../../utils/types.js"; +import { CardTable } from "./Table.js"; +import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util"; +import { useSessionContext } from "../../../../context/session.js"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { ConfirmModal } from "../../../../components/modal/index.js"; +import { LoginPage } from "../../../login/index.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; + +interface Props { + onUnauthorized: () => VNode; + onNotFound: () => VNode; + onCreate: () => void; + onSelect: (slug: string) => void; + onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; +} +export default function TokenFamilyList({ + onCreate, + onSelect, +}: Props): VNode { + const result = useInstanceTokenFamilies(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { lib, state } = useSessionContext(); + const [deleting, setDeleting] = + useState<TalerMerchantApi.TokenFamilySummary | null>(null); + + const { i18n } = useTranslationContext(); + + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + case HttpStatusCode.Unauthorized: { + return <LoginPage /> + } + default: { + assertUnreachable(result); + } + } + } + + return ( + <section class="section is-main-section"> + <NotificationCard notification={notif} /> + + <CardTable + instances={result.body.token_families} + onCreate={onCreate} + onUpdate={async (slug, fam) => { + try { + await lib.instance.updateTokenFamily(state.token, slug, fam); + setNotif({ + message: i18n.str`token family updated successfully`, + type: "SUCCESS", + }); + } catch (error) { + setNotif({ + message: i18n.str`could not update the token family`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + } + return; + }} + onSelect={(tokenFamily) => onSelect(tokenFamily.slug)} + onDelete={(fam) => setDeleting(fam)} + /> + + {deleting && ( + <ConfirmModal + label={`Delete token family`} + description={`Delete the token family "${deleting.name}"`} + danger + active + onCancel={() => setDeleting(null)} + onConfirm={async (): Promise<void> => { + try { + await lib.instance.deleteTokenFamily(state.token, deleting.slug); + setNotif({ + message: i18n.str`Token family "${deleting.name}" (SLUG: ${deleting.slug}) has been deleted`, + type: "SUCCESS", + }); + } catch (error) { + setNotif({ + message: i18n.str`Failed to delete token family`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + } + setDeleting(null); + }} + > + <p> + If you delete the <b>"{deleting.name}"</b> token family (Slug:{" "} + <b>{deleting.slug}</b>), all issued tokens will become invalid. + </p> + <p class="warning"> + Deleting a token family <b>cannot be undone</b>. + </p> + </ConfirmModal> + )} + </section> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/update/UpdatePage.tsx new file mode 100644 index 000000000..5641d261b --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/update/UpdatePage.tsx @@ -0,0 +1,172 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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 Christian Blättler + */ + +import { Duration, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h } from "preact"; +import { useState } from "preact/hooks"; +import * as yup from "yup"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { FormErrors, FormProvider } from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { InputDate } from "../../../../components/form/InputDate.js"; +import { InputDuration } from "../../../../components/form/InputDuration.js"; +import { TokenFamilyUpdateSchema } from "../../../../schemas/index.js"; + +type Entity = Omit<TalerMerchantApi.TokenFamilyUpdateRequest, "duration"> & { + duration: Duration, +}; + +interface Props { + onUpdate: (d: TalerMerchantApi.TokenFamilyUpdateRequest) => Promise<void>; + onBack?: () => void; + tokenFamily: TalerMerchantApi.TokenFamilyUpdateRequest; +} + +function convert(from: TalerMerchantApi.TokenFamilyUpdateRequest) { + const { duration, ...rest } = from; + + const converted = { + duration: Duration.fromTalerProtocolDuration(duration), + }; + return { ...converted, ...rest }; +} + +export function UpdatePage({ onUpdate, onBack, tokenFamily }: Props) { + const [value, valueHandler] = useState<Partial<Entity>>(convert(tokenFamily)); + let errors: FormErrors<Entity> = {}; + + try { + TokenFamilyUpdateSchema.validateSync(value, { + abortEarly: false, + }); + } catch (err) { + if (err instanceof yup.ValidationError) { + const yupErrors = err.inner as yup.ValidationError[]; + errors = yupErrors.reduce( + (prev, cur) => + !cur.path ? prev : { ...prev, [cur.path]: cur.message }, + {}, + ); + } + } + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + + const { duration, ...rest } = value as Required<Entity>; + const result: TalerMerchantApi.TokenFamilyUpdateRequest = { + ...rest, + duration: Duration.toTalerProtocolDuration(duration), + }; + + return onUpdate(result); + } + + const { i18n } = useTranslationContext(); + + 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"> + Token Family: <b>{tokenFamily.name}</b> + </span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column is-four-fifths"> + <FormProvider<Entity> + name="token_family" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <Input<Entity> + name="name" + inputType="text" + label={i18n.str`Name`} + tooltip={i18n.str`user-readable token family name`} + /> + <Input<Entity> + name="description" + inputType="multiline" + label={i18n.str`Description`} + tooltip={i18n.str`token family description for customers`} + /> + <InputDate<Entity> + name="valid_after" + label={i18n.str`Valid After`} + tooltip={i18n.str`token family can issue tokens after this date`} + withTimestampSupport + /> + <InputDate<Entity> + name="valid_before" + label={i18n.str`Valid Before`} + tooltip={i18n.str`token family can issue tokens until this date`} + withTimestampSupport + /> + <InputDuration<Entity> + name="duration" + label={i18n.str`Duration`} + tooltip={i18n.str`validity duration of a issued token`} + withForever + /> + </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> + ); +} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/update/index.tsx index 52f6c6c29..068235e14 100644 --- a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/update/index.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2024 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 @@ -16,7 +16,7 @@ /** * - * @author Sebastian Javier Marchano (sebasjm) + * @author Christian Blättler */ import { @@ -28,69 +28,72 @@ import { Fragment, h, VNode } 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 { Notification } from "../../../../utils/types.js"; import { UpdatePage } from "./UpdatePage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { useOtpDeviceAPI, useOtpDeviceDetails } from "../../../../hooks/otp.js"; +import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util"; +import { useTokenFamilyDetails } from "../../../../hooks/tokenfamily.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { LoginPage } from "../../../login/index.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; -export type Entity = MerchantBackend.OTP.OtpDevicePatchDetails & WithId; +type Entity = TalerMerchantApi.TokenFamilyUpdateRequest; interface Props { onBack?: () => void; onConfirm: () => void; - onUnauthorized: () => VNode; - onNotFound: () => VNode; - onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; - vid: string; + slug: string; } -export default function UpdateValidator({ - vid, +export default function UpdateTokenFamily({ + slug, onConfirm, onBack, - onUnauthorized, - onNotFound, - onLoadError, }: Props): VNode { - const { updateOtpDevice } = useOtpDeviceAPI(); - const result = useOtpDeviceDetails(vid); + const result = useTokenFamilyDetails(slug); const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { lib, state } = useSessionContext(); 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); + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + case HttpStatusCode.Unauthorized: { + return <LoginPage /> + } + default: { + assertUnreachable(result); + } + } + } + + const family: Entity = { + name: result.body.name, + description: result.body.description, + description_i18n: result.body.description_i18n || {}, + duration: result.body.duration, + valid_after: result.body.valid_after, + valid_before: result.body.valid_before, + }; return ( <Fragment> <NotificationCard notification={notif} /> <UpdatePage - device={{ - id: vid, - otp_algorithm: result.data.otp_algorithm, - otp_device_description: result.data.device_description, - otp_key: undefined, - otp_ctr: result.data.otp_ctr - }} + tokenFamily={family} onBack={onBack} onUpdate={(data) => { - return updateOtpDevice(vid, data) + return lib.instance.updateTokenFamily(state.token, slug, data) .then(onConfirm) .catch((error) => { setNotif({ - message: i18n.str`could not update template`, + message: i18n.str`could not update token family`, type: "ERROR", description: error.message, }); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx index 22ad0b8d8..927e36cf7 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx @@ -19,12 +19,12 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { TalerMerchantApi } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { FormProvider } from "../../../../components/form/FormProvider.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; import { CardTable } from "./Table.js"; -import { TalerMerchantApi } from "@gnu-taler/taler-util"; export interface Props { transfers: TalerMerchantApi.TransferDetails[]; @@ -40,7 +40,7 @@ export interface Props { onChangePayTo: (p?: string) => void; payTo?: string; onCreate: () => void; - onDelete: () => void; + onDelete: (wid: TalerMerchantApi.TransferDetails) => void; } export function ListPage({ @@ -73,14 +73,14 @@ export function ListPage({ > <InputSelector name="payto_uri" - label={i18n.str`Account URI`} + label={i18n.str`Bank account`} values={accounts} fromStr={(d) => { const idx = accounts.indexOf(d) if (idx === -1) return undefined; return d }} - placeholder={i18n.str`Select one account`} + placeholder={i18n.str`All accounts`} tooltip={i18n.str`filter by account address`} /> </FormProvider> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx index b9235c669..5687d5e57 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx @@ -25,6 +25,7 @@ import { format } from "date-fns"; import { h, VNode } from "preact"; import { StateUpdater, useState } from "preact/hooks"; import { datetimeFormatForSettings, usePreference } from "../../../../hooks/preference.js"; +import { WithId } from "../../../../declaration.js"; type Entity = TalerMerchantApi.TransferDetails & WithId; @@ -129,12 +130,6 @@ function Table({ <i18n.Translate>Credit</i18n.Translate> </th> <th> - <i18n.Translate>Address</i18n.Translate> - </th> - <th> - <i18n.Translate>Exchange URL</i18n.Translate> - </th> - <th> <i18n.Translate>Confirmed</i18n.Translate> </th> <th> @@ -150,10 +145,8 @@ function Table({ {instances.map((i) => { return ( <tr key={i.id}> - <td>{i.id}</td> + <td title={i.wtid}>{i.wtid.substring(0,16)}...</td> <td>{i.credit_amount}</td> - <td>{i.payto_uri}</td> - <td>{i.exchange_url}</td> <td>{i.confirmed ? i18n.str`yes` : i18n.str`no`}</td> <td>{i.verified ? i18n.str`yes` : i18n.str`no`}</td> <td> @@ -167,13 +160,13 @@ function Table({ : i18n.str`unknown`} </td> <td> - {i.verified === undefined ? ( + {i.verified !== true ? ( <button class="button is-danger is-small has-tooltip-left" data-tooltip={i18n.str`delete selected transfer from the database`} onClick={() => onDelete(i)} > - Delete + <i18n.Translate>Delete</i18n.Translate> </button> ) : undefined} </td> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx index 8b4d1f3cb..6a16446d8 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx @@ -19,8 +19,12 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpStatusCode, TalerError, assertUnreachable } from "@gnu-taler/taler-util"; -import { VNode, h } from "preact"; +import { + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; import { Loading } from "../../../../components/exception/loading.js"; @@ -29,6 +33,10 @@ import { useInstanceTransfers } from "../../../../hooks/transfer.js"; import { LoginPage } from "../../../login/index.js"; import { ListPage } from "./ListPage.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { Notification } from "../../../../utils/types.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; interface Props { onCreate: () => void; @@ -38,25 +46,28 @@ interface Form { payto_uri?: string; } -export default function ListTransfer({ - onCreate, -}: Props): VNode { +export default function ListTransfer({ onCreate }: Props): VNode { const setFilter = (s?: boolean) => setForm({ ...form, verified: s }); + const { i18n } = useTranslationContext(); + + const { state, lib } = useSessionContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); const [position, setPosition] = useState<string | undefined>(undefined); const instance = useInstanceBankAccounts(); - const accounts = !instance || (instance instanceof TalerError) || instance.type === "fail" - ? [] - : instance.body.accounts.map((a) => a.payto_uri); + const accounts = + !instance || instance instanceof TalerError || instance.type === "fail" + ? [] + : instance.body.accounts.map((a) => a.payto_uri); const [form, setForm] = useState<Form>({ payto_uri: "" }); - const shoulUseDefaultAccount = accounts.length === 1 + const shoulUseDefaultAccount = accounts.length === 1; useEffect(() => { if (shoulUseDefaultAccount) { - setForm({...form, payto_uri: accounts[0]}) + setForm({ ...form, payto_uri: accounts[0] }); } - }, [shoulUseDefaultAccount]) + }, [shoulUseDefaultAccount]); const isVerifiedTransfers = form.verified === true; const isNonVerifiedTransfers = form.verified === false; @@ -78,7 +89,7 @@ export default function ListTransfer({ if (result.type === "fail") { switch (result.case) { case HttpStatusCode.Unauthorized: { - return <LoginPage /> + return <LoginPage />; } case HttpStatusCode.NotFound: { return <NotFoundPageOrAdminCreate />; @@ -90,23 +101,47 @@ export default function ListTransfer({ } return ( - <ListPage - accounts={accounts} - transfers={result.body} - onLoadMoreBefore={result.isFirstPage ? undefined: result.loadFirst } - onLoadMoreAfter={result.isLastPage ? undefined : result.loadNext} - onCreate={onCreate} - onDelete={() => { - null; - }} - onShowAll={() => setFilter(undefined)} - onShowUnverified={() => setFilter(false)} - onShowVerified={() => setFilter(true)} - isAllTransfers={isAllTransfers} - isVerifiedTransfers={isVerifiedTransfers} - isNonVerifiedTransfers={isNonVerifiedTransfers} - payTo={form.payto_uri} - onChangePayTo={(p) => setForm((v) => ({ ...v, payto_uri: p }))} - /> + <Fragment> + <NotificationCard notification={notif} /> + + <ListPage + accounts={accounts} + transfers={result.body} + onLoadMoreBefore={result.isFirstPage ? undefined : result.loadFirst} + onLoadMoreAfter={result.isLastPage ? undefined : result.loadNext} + onCreate={onCreate} + onDelete={async (transfer) => { + try { + const resp = await lib.instance.deleteWireTransfer(state.token, String(transfer.transfer_serial_id)); + if (resp.type === "ok") { + setNotif({ + message: i18n.str`Wire transfer "${transfer.wtid.substring(0,16)}..." has been deleted`, + type: "SUCCESS", + }); + } else { + setNotif({ + message: i18n.str`Failed to delete transfer`, + type: "ERROR", + description: resp.detail.hint, + }); + } + } catch (error) { + setNotif({ + message: i18n.str`Failed to delete transfer`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + } + }} + onShowAll={() => setFilter(undefined)} + onShowUnverified={() => setFilter(false)} + onShowVerified={() => setFilter(true)} + isAllTransfers={isAllTransfers} + isVerifiedTransfers={isVerifiedTransfers} + isNonVerifiedTransfers={isNonVerifiedTransfers} + payTo={form.payto_uri} + onChangePayTo={(p) => setForm((v) => ({ ...v, payto_uri: p }))} + /> + </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx index 6aca62582..bcd53ffd0 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx @@ -29,6 +29,7 @@ import { } from "../../../../components/form/FormProvider.js"; import { Input } from "../../../../components/form/Input.js"; import { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { WithId } from "../../../../declaration.js"; type Entity = TalerMerchantApi.WebhookPatchDetails & WithId; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx index 5b2ba7bb9..6c0466dad 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx @@ -36,6 +36,7 @@ import { Notification } from "../../../../utils/types.js"; import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { UpdatePage } from "./UpdatePage.js"; +import { WithId } from "../../../../declaration.js"; export type Entity = TalerMerchantApi.WebhookPatchDetails & WithId; diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx index 0c4b9dd1a..ac23f7f09 100644 --- a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx @@ -45,6 +45,7 @@ export function Settings({ onClose }: { onClose?: () => void }): VNode { const next = s(value); const v: Preferences = { advanceOrderMode: next.advanceOrderMode ?? false, + advanceInstanceMode: next.advanceInstanceMode ?? false, hideMissingAccountUntil: next.hideMissingAccountUntil ?? AbsoluteTime.never(), hideKycUntil: next.hideKycUntil ?? AbsoluteTime.never(), dateFormat: next.dateFormat ?? "ymd", @@ -101,6 +102,11 @@ export function Settings({ onClose }: { onClose?: () => void }): VNode { tooltip={i18n.str`Shows more options in the order creation form`} name="advanceOrderMode" /> + <InputToggle<Preferences> + label={i18n.str`Advance instance settings`} + tooltip={i18n.str`Shows more options in the instance settings form`} + name="advanceInstanceMode" + /> <InputSelector<Preferences> name="dateFormat" label={i18n.str`Date format`} diff --git a/packages/merchant-backoffice-ui/src/schemas/index.ts b/packages/merchant-backoffice-ui/src/schemas/index.ts index 693894ae0..77d23f49a 100644 --- a/packages/merchant-backoffice-ui/src/schemas/index.ts +++ b/packages/merchant-backoffice-ui/src/schemas/index.ts @@ -23,6 +23,8 @@ import { Amounts } from "@gnu-taler/taler-util"; import { isAfter, isFuture } from "date-fns"; import * as yup from "yup"; import { PAYTO_REGEX } from "../utils/constants.js"; +import { MerchantBackend } from "../declaration.js"; +// import { MerchantBackend } from "../declaration.js"; yup.setLocale({ mixed: { @@ -222,3 +224,55 @@ export const NonInventoryProductSchema = yup.object().shape({ .required() .test("amount", "the amount is not valid", currencyWithAmountIsValid), }); + +const timestampSchema = yup.object().shape({ + t_s: yup.mixed().test( + 'is-timestamp', + 'Invalid timestamp', + value => typeof value === 'number' || value === 'never' + ) +}).required(); + +const durationSchema = yup.object().shape({ + d_us: yup.mixed().test( + 'is-duration', + 'Invalid duration', + value => typeof value === 'number' || value === 'forever' + ) +}).required(); + +const tokenFamilyKindSchema = yup.mixed().oneOf<MerchantBackend.TokenFamilies.TokenFamilyKind>(["discount", "subscription"]).required(); + +export const TokenFamilyCreateSchema = yup.object().shape({ + slug: yup.string().ensure().required(), + name: yup.string().required(), + description: yup.string().required(), + // description_i18n: yup.lazy((obj) => + // yup.object().shape( + // Object.keys(obj || {}).reduce((acc, key) => { + // acc[key] = yup.string().required(); + // return acc; + // }, {}) + // ) + // ).optional(), + valid_after: timestampSchema.optional(), + valid_before: timestampSchema, + duration: durationSchema, + kind: tokenFamilyKindSchema, +}); + +export const TokenFamilyUpdateSchema = yup.object().shape({ + name: yup.string().required(), + description: yup.string().required(), + // description_i18n: yup.lazy((obj) => + // yup.object().shape( + // Object.keys(obj).reduce((acc, key) => { + // acc[key] = yup.string().required(); + // return acc; + // }, {}) + // ) + // ), + valid_after: timestampSchema, + valid_before: timestampSchema, + duration: durationSchema, +}); diff --git a/packages/merchant-backoffice-ui/src/scss/toggle.scss b/packages/merchant-backoffice-ui/src/scss/toggle.scss index 6c7346eb3..d3ff22997 100644 --- a/packages/merchant-backoffice-ui/src/scss/toggle.scss +++ b/packages/merchant-backoffice-ui/src/scss/toggle.scss @@ -50,9 +50,25 @@ $green: #56c080; background: $green; &:before { + left: 4px; + } + } + .toggle-checkbox:not(checked)+& { + background: $red; + + &:before { left: 30px; } } + .toggle-checkbox:indeterminate+& { + background: rgba(0, 0, 0, 0.301); + + &:before { + left: 16px; + background: rgba(0, 0, 0, 0.301); + } + } + } .toggle-checkbox { diff --git a/packages/merchant-backoffice-ui/src/utils/table.ts b/packages/merchant-backoffice-ui/src/utils/table.ts index 982b68e5e..5a94654f3 100644 --- a/packages/merchant-backoffice-ui/src/utils/table.ts +++ b/packages/merchant-backoffice-ui/src/utils/table.ts @@ -14,6 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { WithId } from "../declaration.js"; + /** * diff --git a/packages/pogen/package.json b/packages/pogen/package.json index 81d66125f..f57dbad40 100644 --- a/packages/pogen/package.json +++ b/packages/pogen/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/pogen", - "version": "0.11.4", + "version": "0.12.2", "bin": { "pogen": "bin/pogen" }, diff --git a/packages/taler-harness/debian/changelog b/packages/taler-harness/debian/changelog index a891cc7ba..64f750458 100644 --- a/packages/taler-harness/debian/changelog +++ b/packages/taler-harness/debian/changelog @@ -1,3 +1,27 @@ +taler-harness (0.12.2) unstable; urgency=low + + * Release 0.12.2 + + -- Florian Dold <dold@taler.net> Thu, 27 Jun 2024 20:19:19 +0200 + +taler-harness (0.12.1) unstable; urgency=low + + * Release 0.12.1 + + -- Florian Dold <dold@taler.net> Wed, 26 Jun 2024 09:30:32 -0600 + +taler-harness (v0.12.1) unstable; urgency=low + + * Release v0.12.1 + + -- Florian Dold <dold@taler.net> Wed, 26 Jun 2024 09:30:20 -0600 + +taler-harness (0.12.0) unstable; urgency=low + + * Release 0.12.0 + + -- Florian Dold <dold@taler.net> Wed, 26 Jun 2024 16:17:52 +0200 + taler-harness (0.11.4) unstable; urgency=low * Release 0.11.4 diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json index bca870c8b..02bebcfce 100644 --- a/packages/taler-harness/package.json +++ b/packages/taler-harness/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-harness", - "version": "0.11.4", + "version": "0.12.2", "description": "", "engines": { "node": ">=0.12.0" diff --git a/packages/taler-harness/src/bench2.ts b/packages/taler-harness/src/bench2.ts index 90924caec..dc360ae62 100644 --- a/packages/taler-harness/src/bench2.ts +++ b/packages/taler-harness/src/bench2.ts @@ -30,7 +30,6 @@ import { applyRunConfigDefaults, CryptoDispatcher, SynchronousCryptoWorkerFactoryPlain, - Wallet, } from "@gnu-taler/taler-wallet-core"; import { checkReserve, diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts index 4fc462ddf..4cdde6b8d 100644 --- a/packages/taler-harness/src/harness/harness.ts +++ b/packages/taler-harness/src/harness/harness.ts @@ -805,6 +805,11 @@ export class LibeufinBankService "registration_bonus", `${bc.currency}:100`, ); + config.setString( + "libeufin-bank", + "ALLOW_REGISTRATION", + "yes", + ); const cfgFilename = testDir + "/bank.conf"; config.writeTo(cfgFilename, { excludeDefaults: true }); @@ -1468,6 +1473,26 @@ export class ExchangeService implements ExchangeServiceInterface { await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`); } + /** + * Generate a new master public key for the exchange. + */ + async regenerateMasterPub(): Promise<void> { + const cfg = Configuration.load(this.configFilename); + const masterPrivFile = cfg + .getPath("exchange-offline", "master_priv_file") + .required(); + fs.unlinkSync(masterPrivFile); + const exchangeMasterKey = createEddsaKeyPair(); + fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv)); + cfg.setString( + "exchange", + "master_public_key", + encodeCrock(exchangeMasterKey.eddsaPub), + ); + + cfg.writeTo(this.configFilename, { excludeDefaults: true }); + } + async purgeDatabase(): Promise<void> { await sh( this.globalState, diff --git a/packages/taler-harness/src/harness/helpers.ts b/packages/taler-harness/src/harness/helpers.ts index d194b0d36..2720be474 100644 --- a/packages/taler-harness/src/harness/helpers.ts +++ b/packages/taler-harness/src/harness/helpers.ts @@ -118,6 +118,8 @@ export interface EnvOptions { skipWireFeeCreation?: boolean; + walletTestObservability?: boolean; + additionalExchangeConfig?(e: ExchangeService): void; additionalMerchantConfig?(m: MerchantService): void; additionalBankConfig?(b: BankService): void; @@ -556,9 +558,12 @@ export async function createSimpleTestkudosEnvironmentV3( ), }); - const { walletClient, walletService } = await createWalletDaemonWithClient( - t, - { name: "wallet", persistent: true }, + const { walletClient, walletService } = await createWalletDaemonWithClient(t, + { + name: "wallet", + persistent: true, + emitObservabilityEvents: !!opts.walletTestObservability, + }, ); console.log("setup done!"); @@ -580,6 +585,7 @@ export interface CreateWalletArgs { persistent?: boolean; overrideDbPath?: string; config?: PartialWalletRunConfig; + emitObservabilityEvents?: boolean; } export async function createWalletDaemonWithClient( @@ -617,7 +623,7 @@ export async function createWalletDaemonWithClient( const defaultRunConfig = { testing: { skipDefaults: true, - emitObservabilityEvents: !!process.env["TALER_TEST_OBSERVABILITY"], + emitObservabilityEvents: !!process.env["TALER_TEST_OBSERVABILITY"] || !!args.emitObservabilityEvents, }, } satisfies PartialWalletRunConfig; await walletClient.client.call(WalletApiOperation.InitWallet, { diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts index 99b5502d8..864b7530c 100644 --- a/packages/taler-harness/src/index.ts +++ b/packages/taler-harness/src/index.ts @@ -302,8 +302,6 @@ advancedCli const { walletClient, walletService, bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2(t); await walletClient.call(WalletApiOperation.RunIntegrationTestV2, { - amountToSpend: "TESTKUDOS:5" as AmountString, - amountToWithdraw: "TESTKUDOS:10" as AmountString, corebankApiBaseUrl: bank.corebankApiBaseUrl, exchangeBaseUrl: exchange.baseUrl, merchantBaseUrl: merchant.makeInstanceBaseUrl(), @@ -761,7 +759,7 @@ deploymentCli "admin", bankAdminPassword, { - scope: "write", + scope: "readwrite", duration: { d_us: 1000 * 1000 * 10, //10 secs }, @@ -782,6 +780,7 @@ deploymentCli */ let accountPayto: PaytoString; { + logger.info(`token: ${j2s(bankAdminToken)}`); const resp = await bank.createAccount(bankAdminToken, { name: name, password: password, @@ -799,6 +798,7 @@ deploymentCli logger.error( `unable to provision bank account, HTTP response status ${resp.case}`, ); + logger.error(j2s(resp)); process.exit(2); } logger.info(`account ${id} successfully provisioned`); @@ -1042,7 +1042,10 @@ deploymentCli .action(async (args) => { const httpLib = createPlatformHttpLib({}); const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl; - const api = new TalerMerchantManagementHttpClient(baseUrl, httpLib); + const managementApi = new TalerMerchantManagementHttpClient( + baseUrl, + httpLib, + ); const managementToken = createRFC8959AccessTokenEncoded( args.provisionMerchantInstance.managementToken, ); @@ -1059,7 +1062,7 @@ deploymentCli const bankPassword = args.provisionMerchantInstance.bankPassword; const accountPayto = args.provisionMerchantInstance.payto as PaytoString; - const createResp = await api.createInstance(managementToken, { + const createResp = await managementApi.createInstance(managementToken, { address: {}, auth: { method: "token", @@ -1086,18 +1089,28 @@ deploymentCli process.exit(2); } - const createAccountResp = await api.addBankAccount(instanceTokenEnc, { - payto_uri: accountPayto, - credit_facade_url: bankURL, - credit_facade_credentials: - bankUser && bankPassword - ? { - type: "basic", - username: bankUser, - password: bankPassword, - } - : undefined, - }); + const instanceUrl = managementApi.getSubInstanceAPI(instanceId).href; + + const instanceApi = new TalerMerchantInstanceHttpClient( + instanceUrl, + httpLib, + ); + + const createAccountResp = await instanceApi.addBankAccount( + instanceTokenEnc, + { + payto_uri: accountPayto, + credit_facade_url: bankURL, + credit_facade_credentials: + bankUser && bankPassword + ? { + type: "basic", + username: bankUser, + password: bankPassword, + } + : undefined, + }, + ); if (createAccountResp.type != "ok") { console.error( `unable to configure bank account for instance ${instanceId}, status ${createAccountResp.case}`, @@ -1141,6 +1154,7 @@ deploymentCli logger.error( `unable to provision bank account, HTTP response status ${resp.case}`, ); + logger.error(j2s(resp)); process.exit(2); }); diff --git a/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts b/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts new file mode 100644 index 000000000..a66d94b57 --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts @@ -0,0 +1,114 @@ +/* + This file is part of GNU Taler + (C) 2020 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/> + */ + +/** + * Imports. + */ +import { + ExchangeUpdateStatus, + TalerErrorCode, + j2s, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + ExchangeService, + GlobalTestState, + setupDb, +} from "../harness/harness.js"; +import { + createSimpleTestkudosEnvironmentV3, + withdrawViaBankV3, +} from "../harness/helpers.js"; + +/** + * Test the wallet's behavior when the exchange switches to a completely + * new master public keyy. + */ +export async function runExchangeMasterPubChangeTest( + t: GlobalTestState, +): Promise<void> { + // Set up test environment + + const { walletClient, exchange, bankClient, exchangeBankAccount } = + await createSimpleTestkudosEnvironmentV3(t); + + const wres = await withdrawViaBankV3(t, { + walletClient, + amount: "TESTKUDOS:10", + bankClient, + exchange, + }); + + await wres.withdrawalFinishedCond; + + t.logStep("withdrawal-done"); + + const exchangesListOld = await walletClient.call( + WalletApiOperation.ListExchanges, + {}, + ); + + console.log(j2s(exchangesListOld)); + + await exchange.stop(); + + // Instead of reconfiguring the old exchange, we just create a new exchange here + // that runs under the same base URL as the old exchange. + + const db2 = await setupDb(t, { + nameSuffix: "e2", + }); + const exchange2 = ExchangeService.create(t, { + name: "testexchange-2", + currency: "TESTKUDOS", + httpPort: 8081, + database: db2.connStr, + }); + + await exchange2.addBankAccount("1", exchangeBankAccount); + exchange2.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); + await exchange2.start(); + + t.logStep("exchange-restarted"); + + const err = await t.assertThrowsTalerErrorAsync(async () => { + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: exchange.baseUrl, + force: true, + }); + }); + + console.log("updateExchangeEntry err:", j2s(err)); + + const exchangesList = await walletClient.call( + WalletApiOperation.ListExchanges, + {}, + ); + + console.log(j2s(exchangesList)); + + t.assertDeepEqual( + exchangesList.exchanges[0].exchangeUpdateStatus, + ExchangeUpdateStatus.UnavailableUpdate, + ); + t.assertDeepEqual( + exchangesList.exchanges[0].unavailableReason?.code, + TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT, + ); +} + +runExchangeMasterPubChangeTest.suites = ["wallet", "exchange"]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-categories.ts b/packages/taler-harness/src/integrationtests/test-merchant-categories.ts new file mode 100644 index 000000000..5a7b690f7 --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-merchant-categories.ts @@ -0,0 +1,162 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { URL, j2s } from "@gnu-taler/taler-util"; +import { + ExchangeService, + GlobalTestState, + MerchantService, + generateRandomPayto, + harnessHttpLib, + setupDb, +} from "../harness/harness.js"; + +/** + * Do basic checks on instance management and authentication. + */ +export async function runMerchantCategoriesTest(t: GlobalTestState) { + // Set up test environment + + const db = await setupDb(t); + + const exchange = ExchangeService.create(t, { + name: "testexchange-1", + currency: "TESTKUDOS", + httpPort: 8081, + database: db.connStr, + }); + + const merchant = await MerchantService.create(t, { + name: "testmerchant-1", + currency: "TESTKUDOS", + httpPort: 8083, + database: db.connStr, + }); + + // We add the exchange to the config, but note that the exchange won't be started. + merchant.addExchange(exchange); + + await merchant.start(); + await merchant.pingUntilAvailable(); + + // Base URL for the default instance. + const baseUrl = merchant.makeInstanceBaseUrl(); + + { + const r = await harnessHttpLib.fetch(new URL("config", baseUrl).href); + const data = await r.json(); + console.log(data); + t.assertDeepEqual(data.currency, "TESTKUDOS"); + } + + // Instances should initially be empty + { + const r = await harnessHttpLib.fetch( + new URL("management/instances", baseUrl).href, + ); + const data = await r.json(); + t.assertDeepEqual(data.instances, []); + } + + // Add an instance, no auth! + await merchant.addInstanceWithWireAccount({ + id: "default", + name: "Default Instance", + paytoUris: [generateRandomPayto("merchant-default")], + auth: { + method: "external", + }, + }); + + let myNewCategoryId: number; + + { + const url = new URL("private/categories", merchant.makeInstanceBaseUrl()); + const res = await harnessHttpLib.fetch(url.href, { + method: "POST", + body: { + name: "Snacks", + name_i18n: {}, + }, + }); + + console.log(res.requestUrl); + console.log("status", res.status); + const categoryJson = await res.json(); + console.log(categoryJson); + t.assertTrue(res.status >= 200 && res.status < 300); + myNewCategoryId = categoryJson.category_id; + } + + { + const url = new URL("private/products", merchant.makeInstanceBaseUrl()); + const res = await harnessHttpLib.fetch(url.href, { + method: "POST", + body: { + product_id: "foo", + description: "Bla Bla", + unit: "item", + price: "TESTKUDOS:6", + total_stock: -1, + }, + }); + t.assertTrue(res.status >= 200 && res.status < 300); + } + + { + const url = new URL("private/products", merchant.makeInstanceBaseUrl()); + const res = await harnessHttpLib.fetch(url.href, { + method: "POST", + body: { + product_id: "bar", + description: "Bla Bla", + unit: "item", + price: "TESTKUDOS:2", + total_stock: -1, + // FIXME: Don't hardcode + catgories: [myNewCategoryId], + }, + }); + t.assertTrue(res.status >= 200 && res.status < 300); + } + + { + const posUrl = new URL("private/pos", merchant.makeInstanceBaseUrl()); + const res = await harnessHttpLib.fetch(posUrl.href, { + method: "GET", + }); + const posJson = await res.json(); + console.log(j2s(posJson)); + t.assertTrue(res.status >= 200 && res.status < 300); + + t.assertDeepEqual(posJson.products.length, 2); + + const prodFoo = posJson.products.find((x: any) => x.product_id = "foo"); + t.assertTrue(!!prodFoo); + // Only default category + t.assertDeepEqual(prodFoo.categories, [0]); + + const prodBar = posJson.products.find((x: any) => x.product_id = "bar"); + t.assertTrue(!!prodBar); + // This should have the one we assigned to it. + t.assertDeepEqual(prodBar.categories, [myNewCategoryId]); + } +} + +runMerchantCategoriesTest.suites = ["merchant"]; diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts b/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts index 6de3c2e33..53a3c75f4 100644 --- a/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts +++ b/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts @@ -62,6 +62,12 @@ const coinConfigList: CoinConfig[] = [ }, ]; +/** + * Test peer pull payments with a large number of coins. + * + * Since we use an artificallly large number of coins, this + * test is a bit slower than other tests. + */ export async function runPeerPullLargeTest(t: GlobalTestState) { // Set up test environment @@ -102,6 +108,7 @@ async function checkNormalPeerPull( wallet1: WalletClient, wallet2: WalletClient, ): Promise<void> { + t.logStep("starting withdrawal"); const withdrawRes = await withdrawViaBankV2(t, { walletClient: wallet2, bank, @@ -111,6 +118,8 @@ async function checkNormalPeerPull( await withdrawRes.withdrawalFinishedCond; + t.logStep("finished withdrawal"); + const purseExpiration = AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration( AbsoluteTime.now(), @@ -191,4 +200,4 @@ async function checkNormalPeerPull( console.log(`txn2: ${j2s(txn2)}`); } -runPeerPullLargeTest.suites = ["wallet"]; +runPeerPullLargeTest.suites = ["wallet", "slow"]; diff --git a/packages/taler-harness/src/integrationtests/test-refund-auto.ts b/packages/taler-harness/src/integrationtests/test-refund-auto.ts index 6e02071af..582f30299 100644 --- a/packages/taler-harness/src/integrationtests/test-refund-auto.ts +++ b/packages/taler-harness/src/integrationtests/test-refund-auto.ts @@ -54,6 +54,8 @@ export async function runRefundAutoTest(t: GlobalTestState) { // Test case where the auto-refund happens { + t.logStep("start-test-autorefund"); + // Set up order. const orderResp = await merchantClient.createOrder({ order: { @@ -122,6 +124,8 @@ export async function runRefundAutoTest(t: GlobalTestState) { // Now test the case where the auto-refund just expires + t.logStep("start-test-expiry"); + { // Set up order. const orderResp = await merchantClient.createOrder({ @@ -165,7 +169,7 @@ export async function runRefundAutoTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: r1.transactionId, txState: { - major: TransactionMajorState.Pending, + major: TransactionMajorState.Finalizing, minor: TransactionMinorState.AutoRefund, }, }); diff --git a/packages/taler-harness/src/integrationtests/test-revocation.ts b/packages/taler-harness/src/integrationtests/test-revocation.ts index 65aa86f98..8714a3769 100644 --- a/packages/taler-harness/src/integrationtests/test-revocation.ts +++ b/packages/taler-harness/src/integrationtests/test-revocation.ts @@ -51,7 +51,7 @@ async function revokeAllWalletCoins(req: { console.log(coinDump); const usedDenomHashes = new Set<string>(); for (const coin of coinDump.coins) { - usedDenomHashes.add(coin.denom_pub_hash); + usedDenomHashes.add(coin.denomPubHash); } for (const x of usedDenomHashes.values()) { await exchange.revokeDenomination(x); @@ -239,7 +239,7 @@ export async function runRevocationTest(t: GlobalTestState) { const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {}); console.log(coinDump); - const coinPubList = coinDump.coins.map((x) => x.coin_pub); + const coinPubList = coinDump.coins.map((x) => x.coinPub); await walletClient.call(WalletApiOperation.ForceRefresh, { refreshCoinSpecs: coinPubList.map((x) => ({ coinPub: x })), }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts b/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts index bcd7de74b..ef4166760 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts @@ -21,14 +21,10 @@ import { AmountString } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; import { - ExchangeService, - FakebankService, GlobalTestState, - MerchantService, - WalletCli, - generateRandomPayto, setupDb, } from "../harness/harness.js"; +import { createSimpleTestkudosEnvironmentV3 } from "harness/helpers.js"; /** * Test that run-until-done of taler-wallet-cli terminates. @@ -38,64 +34,17 @@ export async function runWalletCliTerminationTest(t: GlobalTestState) { const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")); - const bank = await FakebankService.create(t, { - allowRegistrations: true, - currency: "TESTKUDOS", - database: db.connStr, - httpPort: 8082, - }); - - const exchange = ExchangeService.create(t, { - name: "testexchange-1", - currency: "TESTKUDOS", - httpPort: 8081, - database: db.connStr, - }); - - const merchant = await MerchantService.create(t, { - name: "testmerchant-1", - currency: "TESTKUDOS", - httpPort: 8083, - database: db.connStr, - }); - - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - exchange.addBankAccount("1", exchangeBankAccount); - - bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri); - - await bank.start(); - - await bank.pingUntilAvailable(); + const { + exchange, + bankClient, + walletClient, + } = await createSimpleTestkudosEnvironmentV3(t, coinConfig, {}); - exchange.addCoinConfigList(coinConfig); - - await exchange.start(); - await exchange.pingUntilAvailable(); - - merchant.addExchange(exchange); - - await merchant.start(); - await merchant.pingUntilAvailable(); - - await merchant.addInstanceWithWireAccount({ - id: "default", - name: "Default Instance", - paytoUris: [generateRandomPayto("merchant-default")], - }); - - const wallet = new WalletCli(t, "wallet"); - - await wallet.client.call(WalletApiOperation.WithdrawTestBalance, { - corebankApiBaseUrl: bank.corebankApiBaseUrl, + await walletClient.call(WalletApiOperation.WithdrawTestBalance, { + corebankApiBaseUrl: bankClient.baseUrl, exchangeBaseUrl: exchange.baseUrl, amount: "TESTKUDOS:20" as AmountString, }); - - await wallet.runUntilDone(); } runWalletCliTerminationTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-network-availability.ts b/packages/taler-harness/src/integrationtests/test-wallet-network-availability.ts new file mode 100644 index 000000000..d97737e25 --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-wallet-network-availability.ts @@ -0,0 +1,96 @@ +/* + This file is part of GNU Taler + (C) 2024 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/> + */ + +/** + * Imports. + */ +import { + AmountString, + NotificationType, + ObservabilityEventType, + TransactionMajorState, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation, parseTransactionIdentifier } from "@gnu-taler/taler-wallet-core"; +import { GlobalTestState, generateRandomPayto } from "harness/harness.js"; +import { createSimpleTestkudosEnvironmentV3, withdrawViaBankV3 } from "harness/helpers.js"; +import { TaskRunResultType } from "../../../taler-wallet-core/src/common.js"; + +/** + * Run test for hintNetworkAvailability in wallet-core + */ +export async function runWalletNetworkAvailabilityTest(t: GlobalTestState) { + + // Set up test environment + const { bankClient, walletClient, exchange } = + await createSimpleTestkudosEnvironmentV3(t, undefined, { + // We need this to listen to the network-required observability event + walletTestObservability: true, + }); + + await withdrawViaBankV3(t, { + walletClient, + bankClient, + exchange, + amount: "TESTKUDOS:20", + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + const networkRequiredCond = walletClient.waitForNotificationCond((x) => { + return (x.type === NotificationType.TaskObservabilityEvent + && x.event.type === ObservabilityEventType.ShepherdTaskResult + && x.event.resultType === TaskRunResultType.NetworkRequired + ); + }); + + const refreshCreatedCond = walletClient.waitForNotificationCond((x) => { + return (x.type === NotificationType.TransactionStateTransition && + parseTransactionIdentifier(x.transactionId)?.tag === TransactionType.Refresh + ); + }); + + const refreshDoneCond = walletClient.waitForNotificationCond((x) => { + return (x.type === NotificationType.TransactionStateTransition && + parseTransactionIdentifier(x.transactionId)?.tag === TransactionType.Refresh + && x.newTxState.major === TransactionMajorState.Done + ); + }); + + await walletClient.call(WalletApiOperation.HintNetworkAvailability, { + isNetworkAvailable: false, + }); + + const depositGroupResult = await walletClient.client.call( + WalletApiOperation.CreateDepositGroup, + { + amount: "TESTKUDOS:10.5" as AmountString, + depositPaytoUri: generateRandomPayto("foo"), + }, + ); + + // refresh should not continue due to network-required + await networkRequiredCond; + + await walletClient.call(WalletApiOperation.HintNetworkAvailability, { + isNetworkAvailable: true, + }); + + await refreshCreatedCond; + + // refresh should finish due to network being restored + await refreshDoneCond; +} diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts index 0f1efd35e..7b101bc18 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts @@ -80,7 +80,7 @@ export async function runWalletRefreshErrorsTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.ForceRefresh, { refreshCoinSpecs: [ { - coinPub: coinDump.coins[0].coin_pub, + coinPub: coinDump.coins[0].coinPub, amount: "TESTKUDOS:3" as AmountString, }, ], @@ -95,7 +95,7 @@ export async function runWalletRefreshErrorsTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.ForceRefresh, { refreshCoinSpecs: [ { - coinPub: coinDump.coins[0].coin_pub, + coinPub: coinDump.coins[0].coinPub, amount: "TESTKUDOS:3" as AmountString, }, ], diff --git a/packages/taler-harness/src/integrationtests/test-wallettesting.ts b/packages/taler-harness/src/integrationtests/test-wallettesting.ts index 001081532..73585e206 100644 --- a/packages/taler-harness/src/integrationtests/test-wallettesting.ts +++ b/packages/taler-harness/src/integrationtests/test-wallettesting.ts @@ -25,16 +25,10 @@ import { AmountString, Amounts, CoinStatus } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; +import { GlobalTestState, setupDb } from "../harness/harness.js"; import { - ExchangeService, - GlobalTestState, - MerchantService, - setupDb, - generateRandomPayto, - FakebankService, -} from "../harness/harness.js"; -import { - SimpleTestEnvironmentNg, + SimpleTestEnvironmentNg3, + createSimpleTestkudosEnvironmentV3, createWalletDaemonWithClient, } from "../harness/helpers.js"; @@ -47,57 +41,11 @@ const merchantAuthToken = "secret-token:sandbox"; export async function createMyEnvironment( t: GlobalTestState, coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")), -): Promise<SimpleTestEnvironmentNg> { +): Promise<SimpleTestEnvironmentNg3> { const db = await setupDb(t); - const bank = await FakebankService.create(t, { - allowRegistrations: true, - currency: "TESTKUDOS", - database: db.connStr, - httpPort: 8082, - }); - - const exchange = ExchangeService.create(t, { - name: "testexchange-1", - currency: "TESTKUDOS", - httpPort: 8081, - database: db.connStr, - }); - - const merchant = await MerchantService.create(t, { - name: "testmerchant-1", - currency: "TESTKUDOS", - httpPort: 8083, - database: db.connStr, - }); - - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - exchange.addBankAccount("1", exchangeBankAccount); - - bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri); - - await bank.start(); - - await bank.pingUntilAvailable(); - - exchange.addCoinConfigList(coinConfig); - - await exchange.start(); - await exchange.pingUntilAvailable(); - - merchant.addExchange(exchange); - - await merchant.start(); - await merchant.pingUntilAvailable(); - - await merchant.addInstanceWithWireAccount({ - id: "default", - name: "Default Instance", - paytoUris: [generateRandomPayto("merchant-default")], - }); + const { bankClient, exchange, merchant, exchangeBankAccount } = + await createSimpleTestkudosEnvironmentV3(t, coinConfig, {}); console.log("setup done!"); @@ -114,7 +62,7 @@ export async function createMyEnvironment( merchant, walletClient, walletService, - bank, + bankClient, exchangeBankAccount, }; } @@ -123,13 +71,13 @@ export async function createMyEnvironment( * Run test for basic, bank-integrated withdrawal. */ export async function runWallettestingTest(t: GlobalTestState) { - const { walletClient, bank, exchange, merchant } = + const { walletClient, bankClient, exchange, merchant } = await createMyEnvironment(t); await walletClient.call(WalletApiOperation.RunIntegrationTest, { amountToSpend: "TESTKUDOS:5" as AmountString, amountToWithdraw: "TESTKUDOS:10" as AmountString, - corebankApiBaseUrl: bank.corebankApiBaseUrl, + corebankApiBaseUrl: bankClient.baseUrl, exchangeBaseUrl: exchange.baseUrl, merchantAuthToken: merchantAuthToken, merchantBaseUrl: merchant.makeInstanceBaseUrl(), @@ -152,7 +100,7 @@ export async function runWallettestingTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.WithdrawTestBalance, { amount: "TESTKUDOS:10" as AmountString, - corebankApiBaseUrl: bank.corebankApiBaseUrl, + corebankApiBaseUrl: bankClient.baseUrl, exchangeBaseUrl: exchange.baseUrl, }); @@ -177,7 +125,7 @@ export async function runWallettestingTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.WithdrawTestBalance, { amount: "TESTKUDOS:10" as AmountString, - corebankApiBaseUrl: bank.corebankApiBaseUrl, + corebankApiBaseUrl: bankClient.baseUrl, exchangeBaseUrl: exchange.baseUrl, }); @@ -191,10 +139,10 @@ export async function runWallettestingTest(t: GlobalTestState) { { for (const c of coinDump.coins) { if ( - c.coin_status === CoinStatus.Fresh && - 0 === Amounts.cmp(c.denom_value, "TESTKUDOS:8") + c.coinStatus === CoinStatus.Fresh && + 0 === Amounts.cmp(c.denomValue, "TESTKUDOS:8") ) { - susp = c.coin_pub; + susp = c.coinPub; } } } @@ -235,9 +183,7 @@ export async function runWallettestingTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.ClearDb, {}); await walletClient.call(WalletApiOperation.RunIntegrationTestV2, { - amountToSpend: "TESTKUDOS:5" as AmountString, - amountToWithdraw: "TESTKUDOS:10" as AmountString, - corebankApiBaseUrl: bank.corebankApiBaseUrl, + corebankApiBaseUrl: bankClient.baseUrl, exchangeBaseUrl: exchange.baseUrl, merchantAuthToken: merchantAuthToken, merchantBaseUrl: merchant.makeInstanceBaseUrl(), diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-external.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-external.ts new file mode 100644 index 000000000..3cd02882b --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-withdrawal-external.ts @@ -0,0 +1,101 @@ +/* + This file is part of GNU Taler + (C) 2020 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/> + */ + +/** + * Imports. + */ +import { + TransactionMajorState, + TransactionMinorState, + TransactionType, + WithdrawalType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { GlobalTestState } from "../harness/harness.js"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js"; + +/** + * Test for a withdrawal that is externally confirmed. + */ +export async function runWithdrawalExternalTest(t: GlobalTestState) { + // Set up test environment + + const { walletClient, bankClient, exchange } = + await createSimpleTestkudosEnvironmentV3(t); + + // Create a withdrawal operation + + const bankUser = await bankClient.createRandomBankUser(); + bankClient.setAuth(bankUser); + const wop = await bankClient.createWithdrawalOperation( + bankUser.username, + "TESTKUDOS:10", + ); + + const talerWithdrawUri = wop.taler_withdraw_uri + "?external-confirmation=1"; + + // Hand it to the wallet + + const detResp = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForUri, + { + talerWithdrawUri: talerWithdrawUri, + }, + ); + + const acceptResp = await walletClient.call( + WalletApiOperation.AcceptBankIntegratedWithdrawal, + { + exchangeBaseUrl: detResp.defaultExchangeBaseUrl!!, + talerWithdrawUri, + }, + ); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: acceptResp.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }, + }); + + const txDetails = await walletClient.call( + WalletApiOperation.GetTransactionById, + { + transactionId: acceptResp.transactionId, + }, + ); + + // Now we check that the external-confirmation=1 flag actually did something! + + t.assertDeepEqual(txDetails.type, TransactionType.Withdrawal); + t.assertDeepEqual( + txDetails.withdrawalDetails.type, + WithdrawalType.TalerBankIntegrationApi, + ); + t.assertDeepEqual(txDetails.withdrawalDetails.externalConfirmation, true); + t.assertDeepEqual(txDetails.withdrawalDetails.bankConfirmationUrl, undefined); + + t.logStep("confirming withdrawal operation"); + + await bankClient.confirmWithdrawalOperation(bankUser.username, { + withdrawalOperationId: wop.withdrawal_id, + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); +} + +runWithdrawalExternalTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts index ffc7249b8..7a5aa8bfd 100644 --- a/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts +++ b/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts @@ -49,22 +49,25 @@ export async function runWithdrawalFlexTest(t: GlobalTestState) { console.log(j2s(r1)); + t.assertTrue(!r1.amount); + // Withdraw - const r2 = await walletClient.call( - WalletApiOperation.AcceptBankIntegratedWithdrawal, - { - exchangeBaseUrl: exchange.baseUrl, - talerWithdrawUri: wop.taler_withdraw_uri, - amount: "TESTKUDOS:10", - }, - ); + await walletClient.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: wop.taler_withdraw_uri, + amount: "TESTKUDOS:10", + }); await bankClient.confirmWithdrawalOperation(user.username, { withdrawalOperationId: wop.withdrawal_id, }); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + const bal = await walletClient.call(WalletApiOperation.GetBalances, {}); + + t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:9.72"); } runWithdrawalFlexTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index 4588310b1..eb71396e7 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -42,12 +42,14 @@ import { runDepositTest } from "./test-deposit.js"; import { runExchangeDepositTest } from "./test-exchange-deposit.js"; import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js"; import { runExchangeManagementTest } from "./test-exchange-management.js"; +import { runExchangeMasterPubChangeTest } from "./test-exchange-master-pub-change.js"; import { runExchangePurseTest } from "./test-exchange-purse.js"; import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js"; import { runFeeRegressionTest } from "./test-fee-regression.js"; import { runForcedSelectionTest } from "./test-forced-selection.js"; import { runKycTest } from "./test-kyc.js"; import { runLibeufinBankTest } from "./test-libeufin-bank.js"; +import { runMerchantCategoriesTest } from "./test-merchant-categories.js"; import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion.js"; import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete.js"; import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js"; @@ -106,6 +108,7 @@ import { runWalletDevExperimentsTest } from "./test-wallet-dev-experiments.js"; import { runWalletExchangeUpdateTest } from "./test-wallet-exchange-update.js"; import { runWalletGenDbTest } from "./test-wallet-gendb.js"; import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js"; +import { runWalletNetworkAvailabilityTest } from "./test-wallet-network-availability.js"; import { runWalletNotificationsTest } from "./test-wallet-notifications.js"; import { runWalletObservabilityTest } from "./test-wallet-observability.js"; import { runWalletRefreshErrorsTest } from "./test-wallet-refresh-errors.js"; @@ -116,6 +119,7 @@ import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js"; import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated.js"; import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js"; +import { runWithdrawalExternalTest } from "./test-withdrawal-external.js"; import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js"; import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js"; import { runWithdrawalFlexTest } from "./test-withdrawal-flex.js"; @@ -229,11 +233,15 @@ const allTests: TestMainFunction[] = [ runWalletBlockedPayPeerPullTest, runWalletExchangeUpdateTest, runWalletRefreshErrorsTest, + runWalletNetworkAvailabilityTest, runPeerPullLargeTest, runPeerPushLargeTest, runWithdrawalHandoverTest, runWithdrawalAmountTest, runWithdrawalFlexTest, + runExchangeMasterPubChangeTest, + runMerchantCategoriesTest, + runWithdrawalExternalTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json index 87e6a7cfa..c165489b3 100644 --- a/packages/taler-util/package.json +++ b/packages/taler-util/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-util", - "version": "0.11.4", + "version": "0.12.2", "description": "Generic helper functionality for GNU Taler", "type": "module", "types": "./lib/index.node.d.ts", diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts index 9378d25e8..deb5d017b 100644 --- a/packages/taler-util/src/errors.ts +++ b/packages/taler-util/src/errors.ts @@ -107,6 +107,10 @@ export interface DetailsMap { requestUrl: string; requestMethod: string; httpStatusCode: number; + /** + * Original response which is malformed + */ + response?: string; validationError?: string; /** * Content type of the response, usually only specified if not the @@ -166,6 +170,14 @@ export interface DetailsMap { [TalerErrorCode.WALLET_DB_UNAVAILABLE]: { innerError: TalerErrorDetail | undefined; }; + [TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED]: { + exchangeBaseUrl: string; + tosStatus: string; + currentEtag: string | undefined; + }; + [TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT]: { + detail?: string; + }; } type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : empty; @@ -235,6 +247,21 @@ type TalerHttpErrorsDetails = { export type TalerHttpError = TalerHttpErrorsDetails[keyof TalerHttpErrorsDetails]; +/** + * Construct typed error details. + * Fills in the hint with a default based on the error code name. + */ +export function makeTalerErrorDetail<C extends TalerErrorCode>( + code: C, + errBody: ErrBody<C>, + hint?: string, +): TalerErrorDetail { + if (!hint) { + hint = getDefaultHint(code); + } + return { code, hint, ...errBody }; +} + export class TalerError<T = any> extends Error { errorDetail: TalerErrorDetail & T; cause: Error | undefined; diff --git a/packages/taler-util/src/http-client/bank-integration.ts b/packages/taler-util/src/http-client/bank-integration.ts index e07b6c5fa..1e0f7e79c 100644 --- a/packages/taler-util/src/http-client/bank-integration.ts +++ b/packages/taler-util/src/http-client/bank-integration.ts @@ -18,6 +18,7 @@ import { HttpRequestLibrary, readTalerErrorResponse } from "../http-common.js"; import { HttpStatusCode } from "../http-status-codes.js"; import { createPlatformHttpLib } from "../http.js"; import { LibtoolVersion } from "../libtool-version.js"; +import { Logger } from "../logging.js"; import { FailCasesByMethod, ResultByMethod, @@ -46,11 +47,15 @@ export type TalerBankIntegrationErrorsByMethod< prop extends keyof TalerBankIntegrationHttpClient, > = FailCasesByMethod<TalerBankIntegrationHttpClient, prop>; +const logger = new Logger("bank-integration.ts"); + /** * The API is used by the wallets. */ export class TalerBankIntegrationHttpClient { - public readonly PROTOCOL_VERSION = "2:0:0"; + public static readonly PROTOCOL_VERSION = "2:0:1"; + public readonly PROTOCOL_VERSION = + TalerBankIntegrationHttpClient.PROTOCOL_VERSION; httpLib: HttpRequestLibrary; @@ -79,6 +84,7 @@ export class TalerBankIntegrationHttpClient { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForIntegrationBankConfig()); default: + logger.warn(`config request failed, status ${resp.status}`) return opUnknownFailure(resp, await readTalerErrorResponse(resp)); } } diff --git a/packages/taler-util/src/http-client/challenger.ts b/packages/taler-util/src/http-client/challenger.ts index aa530570d..6a920749c 100644 --- a/packages/taler-util/src/http-client/challenger.ts +++ b/packages/taler-util/src/http-client/challenger.ts @@ -1,29 +1,31 @@ import { HttpRequestLibrary, readTalerErrorResponse } from "../http-common.js"; import { HttpStatusCode } from "../http-status-codes.js"; import { createPlatformHttpLib } from "../http.js"; -import { TalerCoreBankCacheEviction } from "../index.node.js"; import { LibtoolVersion } from "../libtool-version.js"; import { FailCasesByMethod, - RedirectResult, ResultByMethod, - opFixedSuccess, opKnownAlternativeFailure, opKnownHttpFailure, opSuccessFromHttp, - opUnknownFailure, + opUnknownFailure } from "../operation.js"; import { AccessToken, - codecForChallengeCreateResponse, + codecForChallengeInvalidPinResponse, + codecForChallengeResponse, codecForChallengeSetupResponse, + codecForChallengeSolveResponse, codecForChallengeStatus, codecForChallengerAuthResponse, codecForChallengerInfoResponse, - codecForChallengerTermsOfServiceResponse, - codecForInvalidPinResponse, + codecForChallengerTermsOfServiceResponse } from "./types.js"; -import { CacheEvictor, makeBearerTokenAuthHeader, nullEvictor } from "./utils.js"; +import { + CacheEvictor, + makeBearerTokenAuthHeader, + nullEvictor, +} from "./utils.js"; export type ChallengerResultByMethod<prop extends keyof ChallengerHttpClient> = ResultByMethod<ChallengerHttpClient, prop>; @@ -32,6 +34,7 @@ export type ChallengerErrorsByMethod<prop extends keyof ChallengerHttpClient> = export enum ChallengerCacheEviction { CREATE_CHALLENGE, + SOLVE_CHALLENGE, } /** @@ -45,7 +48,7 @@ export class ChallengerHttpClient { readonly baseUrl: string, httpClient?: HttpRequestLibrary, cacheEvictor?: CacheEvictor<ChallengerCacheEviction>, - ) { + ) { this.httpLib = httpClient ?? createPlatformHttpLib(); this.cacheEvictor = cacheEvictor ?? nullEvictor; } @@ -116,7 +119,6 @@ export class ChallengerHttpClient { if (state) { url.searchParams.set("state", state); } - // url.searchParams.set("scope", "code"); const resp = await this.httpLib.fetch(url.href, { method: "POST", }); @@ -158,13 +160,8 @@ export class ChallengerHttpClient { await this.cacheEvictor.notifySuccess( ChallengerCacheEviction.CREATE_CHALLENGE, ); - return opSuccessFromHttp(resp, codecForChallengeCreateResponse()); + return opSuccessFromHttp(resp, codecForChallengeResponse()); } - case HttpStatusCode.Found: - const redirect = resp.headers.get("Location")!; - return opFixedSuccess<RedirectResult>({ - redirectURL: new URL(redirect), - }); case HttpStatusCode.BadRequest: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: @@ -197,19 +194,16 @@ export class ChallengerHttpClient { redirect: "manual", }); switch (resp.status) { - case HttpStatusCode.Found: - const redirect = resp.headers.get("Location")!; - return opFixedSuccess<RedirectResult>({ - redirectURL: new URL(redirect), - }); + case HttpStatusCode.Ok: { + await this.cacheEvictor.notifySuccess( + ChallengerCacheEviction.SOLVE_CHALLENGE, + ); + return opSuccessFromHttp(resp, codecForChallengeSolveResponse()); + } case HttpStatusCode.BadRequest: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Forbidden: - return opKnownAlternativeFailure( - resp, - resp.status, - codecForInvalidPinResponse(), - ); + return opKnownAlternativeFailure(resp, HttpStatusCode.Forbidden, codecForChallengeInvalidPinResponse()); case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotAcceptable: diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts index 892971fee..10afdc8eb 100644 --- a/packages/taler-util/src/http-client/merchant.ts +++ b/packages/taler-util/src/http-client/merchant.ts @@ -26,6 +26,7 @@ import { codecForAccountAddResponse, codecForAccountKycRedirects, codecForAccountsSummaryResponse, + codecForBankAccountDetail, codecForBankAccountEntry, codecForClaimResponse, codecForInstancesResponse, @@ -707,7 +708,7 @@ export class TalerMerchantInstanceHttpClient { switch (resp.status) { case HttpStatusCode.Ok: - return opSuccessFromHttp(resp, codecForBankAccountEntry()); + return opSuccessFromHttp(resp, codecForBankAccountDetail()); case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: @@ -882,9 +883,8 @@ export class TalerMerchantInstanceHttpClient { default: return opUnknownFailure(resp, await readTalerErrorResponse(resp)); } - } - + /** * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-products-$PRODUCT_ID */ diff --git a/packages/taler-util/src/http-client/types.ts b/packages/taler-util/src/http-client/types.ts index 0ef0bd65a..3816b1598 100644 --- a/packages/taler-util/src/http-client/types.ts +++ b/packages/taler-util/src/http-client/types.ts @@ -572,21 +572,22 @@ export const codecForAccountAddResponse = export const codecForAccountsSummaryResponse = (): Codec<TalerMerchantApi.AccountsSummaryResponse> => buildCodecForObject<TalerMerchantApi.AccountsSummaryResponse>() - .property("accounts", codecForList(codecForBankAccountSummaryEntry())) + .property("accounts", codecForList(codecForBankAccountEntry())) .build("TalerMerchantApi.AccountsSummaryResponse"); -export const codecForBankAccountSummaryEntry = - (): Codec<TalerMerchantApi.BankAccountSummaryEntry> => - buildCodecForObject<TalerMerchantApi.BankAccountSummaryEntry>() - .property("payto_uri", codecForPaytoString()) - .property("h_wire", codecForString()) - .build("TalerMerchantApi.BankAccountSummaryEntry"); - export const codecForBankAccountEntry = (): Codec<TalerMerchantApi.BankAccountEntry> => buildCodecForObject<TalerMerchantApi.BankAccountEntry>() .property("payto_uri", codecForPaytoString()) .property("h_wire", codecForString()) + .property("active", codecOptional(codecForBoolean())) + .build("TalerMerchantApi.BankAccountEntry"); + +export const codecForBankAccountDetail = + (): Codec<TalerMerchantApi.BankAccountDetail> => + buildCodecForObject<TalerMerchantApi.BankAccountDetail>() + .property("payto_uri", codecForPaytoString()) + .property("h_wire", codecForString()) .property("salt", codecForString()) .property("credit_facade_url", codecOptional(codecForURL())) .property("active", codecOptional(codecForBoolean())) @@ -903,7 +904,6 @@ export const codecForTemplateDetails = .property("template_description", codecForString()) .property("otp_id", codecOptional(codecForString())) .property("template_contract", codecForTemplateContractDetails()) - .property("required_currency", codecOptional(codecForString())) .property( "editable_defaults", codecOptional(codecForTemplateContractDetailsDefaults()), @@ -932,7 +932,6 @@ export const codecForWalletTemplateDetails = (): Codec<TalerMerchantApi.WalletTemplateDetails> => buildCodecForObject<TalerMerchantApi.WalletTemplateDetails>() .property("template_contract", codecForTemplateContractDetails()) - .property("required_currency", codecOptional(codecForString())) .property( "editable_defaults", codecOptional(codecForTemplateContractDetailsDefaults()), @@ -1141,7 +1140,7 @@ export const codecForWithdrawalPublicInfo = codecForConstString("confirmed"), ), ) - .property("amount", codecForAmountString()) + .property("amount", codecOptional(codecForAmountString())) .property("username", codecForString()) .property("selected_reserve_pub", codecOptional(codecForString())) .property( @@ -1322,6 +1321,7 @@ export const codecForBankWithdrawalOperationStatus = .property("wire_types", codecForList(codecForString())) .property("selected_reserve_pub", codecOptional(codecForString())) .property("selected_exchange_account", codecOptional(codecForString())) + .property("max_amount", codecOptional(codecForAmountString())) .build("TalerBankIntegrationApi.BankWithdrawalOperationStatus"); export const codecForBankWithdrawalOperationPostResponse = @@ -1567,6 +1567,14 @@ export const codecForChallengerTermsOfServiceResponse = .property("name", codecForConstString("challenger")) .property("version", codecForString()) .property("implementation", codecOptional(codecForString())) + .property("restrictions", codecOptional(codecForMap(codecForAny()))) + .property( + "address_type", + codecForEither( + codecForConstString("phone"), + codecForConstString("email"), + ), + ) .build("ChallengerApi.ChallengerTermsOfServiceResponse"); export const codecForChallengeSetupResponse = @@ -1578,25 +1586,46 @@ export const codecForChallengeSetupResponse = export const codecForChallengeStatus = (): Codec<ChallengerApi.ChallengeStatus> => buildCodecForObject<ChallengerApi.ChallengeStatus>() - .property("restrictions", codecOptional(codecForMap(codecForAny()))) .property("fix_address", codecForBoolean()) + .property("solved", codecForBoolean()) .property("last_address", codecOptional(codecForMap(codecForAny()))) .property("changes_left", codecForNumber()) + .property("retransmission_time", codecForTimestamp) + .property("pin_transmissions_left", codecForNumber()) + .property("auth_attempts_left", codecForNumber()) .build("ChallengerApi.ChallengeStatus"); + +export const codecForChallengeResponse = + (): Codec<ChallengerApi.ChallengeResponse> => + buildCodecForUnion<ChallengerApi.ChallengeResponse>() + .discriminateOn("type") + .alternative("completed", codecForChallengeRedirect()) + .alternative("created", codecForChallengeCreateResponse()) + .build("ChallengerApi.ChallengeResponse"); + export const codecForChallengeCreateResponse = (): Codec<ChallengerApi.ChallengeCreateResponse> => buildCodecForObject<ChallengerApi.ChallengeCreateResponse>() .property("attempts_left", codecForNumber()) + .property("type", codecForConstString("created")) .property("address", codecForAny()) .property("transmitted", codecForBoolean()) - .property("next_tx_time", codecForString()) + .property("retransmission_time", codecForTimestamp) .build("ChallengerApi.ChallengeCreateResponse"); -export const codecForInvalidPinResponse = +export const codecForChallengeRedirect = + (): Codec<ChallengerApi.ChallengeRedirect> => + buildCodecForObject<ChallengerApi.ChallengeRedirect>() + .property("type", codecForConstString("completed")) + .property("redirect_url", codecForString()) + .build("ChallengerApi.ChallengeRedirect"); + +export const codecForChallengeInvalidPinResponse = (): Codec<ChallengerApi.InvalidPinResponse> => buildCodecForObject<ChallengerApi.InvalidPinResponse>() .property("ec", codecOptional(codecForNumber())) .property("hint", codecForAny()) + .property("type", codecForConstString("pending")) .property("addresses_left", codecForNumber()) .property("pin_transmissions_left", codecForNumber()) .property("auth_attempts_left", codecForNumber()) @@ -1604,6 +1633,14 @@ export const codecForInvalidPinResponse = .property("no_challenge", codecForBoolean()) .build("ChallengerApi.InvalidPinResponse"); +export const codecForChallengeSolveResponse = + (): Codec<ChallengerApi.ChallengeSolveResponse> => + buildCodecForUnion<ChallengerApi.ChallengeSolveResponse>() + .discriminateOn("type") + .alternative("completed", codecForChallengeRedirect()) + .alternative("pending", codecForChallengeInvalidPinResponse()) + .build("ChallengerApi.ChallengeSolveResponse"); + export const codecForChallengerAuthResponse = (): Codec<ChallengerApi.ChallengerAuthResponse> => buildCodecForObject<ChallengerApi.ChallengerAuthResponse>() @@ -2210,13 +2247,6 @@ export namespace TalerCorebankApi { // still change the suggestion. // @since **vC2EC** suggested_amount?: AmountString; - - // The non-Taler card fees the customer will have - // to pay to the account owner, bank and/or - // payment service provider - // they are using to make this withdrawal. - // @since **vC2EC** - card_fees?: AmountString; } export interface BankAccountCreateWithdrawalResponse { @@ -2236,7 +2266,13 @@ export namespace TalerCorebankApi { // Amount that will be withdrawn with this operation // (raw amount without fee considerations). - amount: AmountString; + amount?: AmountString; + + // Suggestion for the amount to be withdrawn with this + // operation. Given if a suggestion was made but the + // user may still change the amount. + // Optional since **vC2EC**. + suggested_amount?: AmountString; // Account username username: string; @@ -4046,18 +4082,22 @@ export namespace TalerMerchantApi { export interface AccountsSummaryResponse { // List of accounts that are known for the instance. - accounts: BankAccountSummaryEntry[]; + accounts: BankAccountEntry[]; } // TODO: missing in docs - export interface BankAccountSummaryEntry { + export interface BankAccountEntry { // payto:// URI of the account. payto_uri: PaytoString; // Hash over the wire details (including over the salt). h_wire: HashCode; + + // true if this account is active, + // false if it is historic. + active?: boolean; } - export interface BankAccountEntry { + export interface BankAccountDetail { // payto:// URI of the account. payto_uri: PaytoString; @@ -4752,17 +4792,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateContractDetails { // Human-readable summary for the template. @@ -4814,17 +4843,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateSummaryResponse { @@ -4850,17 +4868,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateDetails { @@ -4879,17 +4886,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface UsingTemplateDetails { // Summary of the template @@ -5400,6 +5396,19 @@ export namespace ChallengerApi { // URN of the implementation (needed to interpret 'revision' in version). // @since v0, may become mandatory in the future. implementation?: string; + + // Object; map of keys (names of the fields of the address + // to be entered by the user) to objects with a "regex" (string) + // containing an extended Posix regular expression for allowed + // address field values, and a "hint"/"hint_i18n" giving a + // human-readable explanation to display if the value entered + // by the user does not match the regex. Keys that are not mapped + // to such an object have no restriction on the value provided by + // the user. See "ADDRESS_RESTRICTIONS" in the challenger configuration. + restrictions: Record<string, Restriction> | undefined; + + // @since v2. + address_type: "email" | "phone"; } export interface ChallengeSetupResponse { @@ -5414,16 +5423,6 @@ export namespace ChallengerApi { } export interface ChallengeStatus { - // Object; map of keys (names of the fields of the address - // to be entered by the user) to objects with a "regex" (string) - // containing an extended Posix regular expression for allowed - // address field values, and a "hint"/"hint_i18n" giving a - // human-readable explanation to display if the value entered - // by the user does not match the regex. Keys that are not mapped - // to such an object have no restriction on the value provided by - // the user. See "ADDRESS_RESTRICTIONS" in the challenger configuration. - restrictions: Record<string, Restriction> | undefined; - // indicates if the given address cannot be changed anymore, the // form should be read-only if set to true. fix_address: boolean; @@ -5435,9 +5434,37 @@ export namespace ChallengerApi { // number of times the address can still be changed, may or may not be // shown to the user changes_left: Integer; + + // is the challenge already solved? + solved: boolean; + + // when we would re-transmit the challenge the next + // time (at the earliest) if requested by the user + // only present if challenge already created + // @since v2 + retransmission_time: Timestamp; + + // how many times might the PIN still be retransmitted + // only present if challenge already created + // @since v2 + pin_transmissions_left: Integer; + + // how many times might the user still try entering the PIN code + // only present if challenge already created + // @since v2 + auth_attempts_left: Integer; + } + + export type ChallengeResponse = ChallengeRedirect | ChallengeCreateResponse; + + export interface ChallengeRedirect { + type: "completed"; + // challenge is completed, use should redirect here + redirect_url: string; } export interface ChallengeCreateResponse { + type: "created"; // how many more attempts are allowed, might be shown to the user, // highlighting might be appropriate for low values such as 1 or 2 (the // form will never be used if the value is zero) @@ -5453,10 +5480,14 @@ export namespace ChallengerApi { // timestamp explaining when we would re-transmit the challenge the next // time (at the earliest) if requested by the user - next_tx_time: string; + retransmission_time: TalerProtocolTimestamp; } + export type ChallengeSolveResponse = ChallengeRedirect | InvalidPinResponse; + export interface InvalidPinResponse { + type: "pending"; + // numeric Taler error code, should be shown to indicate the error // compactly for reporting to developers ec?: number; diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts index d8cd36287..34be5a1d4 100644 --- a/packages/taler-util/src/http-common.ts +++ b/packages/taler-util/src/http-common.ts @@ -154,6 +154,7 @@ export async function readTalerErrorResponse( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), validationError: e.toString(), }, "Couldn't parse JSON format from error response", @@ -173,6 +174,7 @@ export async function readTalerErrorResponse( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), }, "Error response did not contain error code", ); @@ -193,6 +195,7 @@ export async function readUnexpectedResponseDetails( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), validationError: e.toString(), }, "Couldn't parse JSON format from error response", @@ -206,6 +209,7 @@ export async function readUnexpectedResponseDetails( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), }, "Error response did not contain error code", ); @@ -242,6 +246,7 @@ export async function readSuccessResponseJsonOrErrorCode<T>( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), validationError: e.toString(), }, "Couldn't parse JSON format from response", @@ -257,6 +262,7 @@ export async function readSuccessResponseJsonOrErrorCode<T>( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), validationError: e.toString(), }, "Response invalid", @@ -282,6 +288,7 @@ export async function readResponseJsonOrErrorCode<T>( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), validationError: e.toString(), }, "Couldn't parse JSON format from response", @@ -297,6 +304,7 @@ export async function readResponseJsonOrErrorCode<T>( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), validationError: e.toString(), }, "Response invalid", @@ -376,6 +384,7 @@ export async function readSuccessResponseTextOrErrorCode<T>( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), validationError: e.toString(), }, "Couldn't parse JSON format from error response", @@ -389,6 +398,7 @@ export async function readSuccessResponseTextOrErrorCode<T>( { httpStatusCode: httpResponse.status, requestUrl: httpResponse.requestUrl, + response: await httpResponse.text(), requestMethod: httpResponse.requestMethod, }, "Error response did not contain error code", @@ -420,6 +430,7 @@ export async function checkSuccessResponseOrThrow( requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, + response: await httpResponse.text(), validationError: e.toString(), }, "Couldn't parse JSON format from error response", @@ -433,6 +444,7 @@ export async function checkSuccessResponseOrThrow( { httpStatusCode: httpResponse.status, requestUrl: httpResponse.requestUrl, + response: await httpResponse.text(), requestMethod: httpResponse.requestMethod, }, "Error response did not contain error code", diff --git a/packages/taler-util/src/http-impl.node.ts b/packages/taler-util/src/http-impl.node.ts index 45a12c258..d27fd878d 100644 --- a/packages/taler-util/src/http-impl.node.ts +++ b/packages/taler-util/src/http-impl.node.ts @@ -181,14 +181,14 @@ export class HttpLibImpl implements HttpRequestLibrary { return arg + " '" + String(v) + "'"; } console.log( - `curl -X ${options.method} "${parsedUrl.href}" ${headers} ${ifUndefined( + `TALER_API_DEBUG: curl -X ${options.method} "${parsedUrl.href}" ${headers} ${ifUndefined( "-d", payload, )}`, ); } - let timeoutHandle: NodeJS.Timer | undefined = undefined; + let timeoutHandle: NodeJS.Timeout | undefined = undefined; let cancelCancelledHandler: (() => void) | undefined = undefined; const doCleanup = () => { @@ -236,6 +236,9 @@ export class HttpLibImpl implements HttpRequestLibrary { }, }; doCleanup(); + if (SHOW_CURL_HTTP_REQUEST) { + console.log(`TALER_API_DEBUG: ${textDecoder.decode(data)}`) + } resolve(resp); }); res.on("error", (e) => { diff --git a/packages/taler-util/src/http-impl.qtart.ts b/packages/taler-util/src/http-impl.qtart.ts index f60c82fc3..ea628676a 100644 --- a/packages/taler-util/src/http-impl.qtart.ts +++ b/packages/taler-util/src/http-impl.qtart.ts @@ -72,7 +72,7 @@ export class HttpLibImpl implements HttpRequestLibrary { async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { const method = (opt?.method ?? "GET").toUpperCase(); - logger.trace(`Requesting ${method} ${url}`); + logger.trace(`Requesting (Hi Marc) ${method} ${url}`); const parsedUrl = new URL(url); if (this.throttlingEnabled && this.throttle.applyThrottle(url)) { @@ -102,8 +102,8 @@ export class HttpLibImpl implements HttpRequestLibrary { if (opt?.headers) { Object.entries(opt?.headers).forEach(([key, value]) => { if (value === undefined) return; - requestHeadersMap[key] = value - }) + requestHeadersMap[key] = value; + }); } let headersList: string[] = []; for (let headerName of Object.keys(requestHeadersMap)) { @@ -115,13 +115,12 @@ export class HttpLibImpl implements HttpRequestLibrary { const cancelPromCap = openPromise<QjsHttpResp>(); + logger.trace(`calling qtart fetchHttp`); + // Just like WHATWG fetch(), the qjs http client doesn't // really support cancellation, so cancellation here just // means that the result is ignored! - const { - promise: fetchProm, - cancelFn - } = qjsOs.fetchHttp(url, { + const { promise: fetchProm, cancelFn } = qjsOs.fetchHttp(url, { method, data, headers: headersList, @@ -138,6 +137,7 @@ export class HttpLibImpl implements HttpRequestLibrary { if (opt?.cancellationToken) { cancelCancelledHandler = opt.cancellationToken.onCancelled(() => { + logger.trace(`cancelling quickjs request`); cancelFn(); cancelPromCap.reject(new RequestCancelledError()); }); @@ -147,6 +147,7 @@ export class HttpLibImpl implements HttpRequestLibrary { try { res = await Promise.race([fetchProm, cancelPromCap.promise]); } catch (e) { + logger.trace(`got exception while waiting for qtart http response`); if (e instanceof RequestCancelledError) { throw TalerError.fromDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, @@ -172,6 +173,8 @@ export class HttpLibImpl implements HttpRequestLibrary { throw e; } + logger.trace(`got qtart http response, status ${res.status}`); + if (timeoutHandle != null) { clearTimeout(timeoutHandle); } diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index 9f99f2f5a..287e03584 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -50,6 +50,7 @@ export * from "./observability.js"; export * from "./operation.js"; export * from "./payto.js"; export * from "./promises.js"; +export * from "./qr.js"; export * from "./rfc3548.js"; export * from "./taler-crypto.js"; export * from "./taler-types.js"; diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts index a8a8c3299..49952295a 100644 --- a/packages/taler-util/src/notifications.ts +++ b/packages/taler-util/src/notifications.ts @@ -24,7 +24,11 @@ */ import { AbsoluteTime } from "./time.js"; import { TransactionState } from "./transactions-types.js"; -import { ExchangeEntryState, TalerErrorDetail } from "./wallet-types.js"; +import { + ExchangeEntryState, + TalerErrorDetail, + TransactionIdStr, +} from "./wallet-types.js"; export enum NotificationType { BalanceChange = "balance-change", @@ -134,6 +138,12 @@ export enum ObservabilityEventType { CryptoFinishSuccess = "crypto-finish-success", CryptoFinishError = "crypto-finish-error", Message = "message", + /** + * Declare that an observability event is relevant to a particular transaction. + * If emitted from a request/task, all past/future events for that request/task + * should be shown for the transaction as well. + */ + DeclareConcernsTransaction = "declare-concerns-transaction", } export type ObservabilityEvent = @@ -217,6 +227,10 @@ export type ObservabilityEvent = | { type: ObservabilityEventType.Message; contents: string; + } + | { + type: ObservabilityEventType.DeclareConcernsTransaction; + transactionId: TransactionIdStr; }; export interface BackupOperationErrorNotification { diff --git a/packages/taler-util/src/operation.ts b/packages/taler-util/src/operation.ts index e2ab9d4e4..2d17238dc 100644 --- a/packages/taler-util/src/operation.ts +++ b/packages/taler-util/src/operation.ts @@ -146,7 +146,10 @@ export function opKnownTalerFailure<T extends TalerErrorCode>( return { type: "fail", case: s, detail }; } -export function opUnknownFailure(resp: HttpResponse, error: TalerErrorDetail): never { +export function opUnknownFailure( + resp: HttpResponse, + error: TalerErrorDetail, +): never { throw TalerError.fromDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, { @@ -179,15 +182,51 @@ export function narrowOpSuccessOrThrow<Body, ErrorEnum>( } } +export async function succeedOrThrow<R, E>( + promise: Promise<OperationResult<R, E>>, +): Promise<R> { + const resp = await promise; + if (isOperationOk(resp)) { + return resp.body; + } + + if (isOperationFail(resp)) { + throw TalerError.fromUncheckedDetail({ ...resp, case: resp.case } as any); + } + throw TalerError.fromException(resp); +} + +export async function failOrThrow<E>( + s: E, + promise: Promise<OperationResult<unknown, E>>, +): Promise<TalerErrorDetail | undefined> { + const resp = await promise; + if (isOperationOk(resp)) { + throw TalerError.fromException( + new Error(`request succeed but failure "${s}" was expected`), + ); + } + if (isOperationFail(resp) && resp.case === s) { + return resp.detail; + } + throw TalerError.fromException( + new Error( + `request failed with "${JSON.stringify( + resp, + )}" but case "${s}" was expected`, + ), + ); +} + export type ResultByMethod< TT extends object, p extends keyof TT, > = TT[p] extends (...args: any[]) => infer Ret ? Ret extends Promise<infer Result> - ? Result extends OperationResult<any, any> - ? Result - : never - : never //api always use Promises + ? Result extends OperationResult<any, any> + ? Result + : never + : never //api always use Promises : never; //error cases just for functions export type FailCasesByMethod<TT extends object, p extends keyof TT> = Exclude< @@ -195,4 +234,4 @@ export type FailCasesByMethod<TT extends object, p extends keyof TT> = Exclude< OperationOk<any> >; -export type RedirectResult = { redirectURL: URL } +export type RedirectResult = { redirectURL: URL }; diff --git a/packages/taler-util/src/payto.test.ts b/packages/taler-util/src/payto.test.ts index 66a05b3a2..1b6907cbb 100644 --- a/packages/taler-util/src/payto.test.ts +++ b/packages/taler-util/src/payto.test.ts @@ -16,7 +16,7 @@ import test from "ava"; -import { parsePaytoUri } from "./payto.js"; +import { PaytoString, parsePaytoUri, stringifyPaytoUri } from "./payto.js"; test("basic payto parsing", (t) => { const r1 = parsePaytoUri("https://example.com/"); @@ -29,3 +29,16 @@ test("basic payto parsing", (t) => { t.is(r3?.targetType, "x-taler-bank"); t.is(r3?.targetPath, "123"); }); + +test("parsing payto and stringify again", (t) => { + const payto1 = + "payto://iban/DE1231231231?reciever-name=John%20Doe" as PaytoString; + + t.is(stringifyPaytoUri(parsePaytoUri(payto1)!), payto1); +}); +test("parsing payto with % carh", (t) => { + const payto1 = + "payto://iban/DE7763544441436?receiver-name=Test%20123%2B-%24%25%5E%3Cem%3Ehi%3C%2Fem%3E" as PaytoString; + + t.is(stringifyPaytoUri(parsePaytoUri(payto1)!), payto1); +}); diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts index 39c25cffd..2b55cdf64 100644 --- a/packages/taler-util/src/payto.ts +++ b/packages/taler-util/src/payto.ts @@ -15,7 +15,22 @@ */ import { generateFakeSegwitAddress } from "./bitcoin.js"; -import { Codec, Context, DecodingError, buildCodecForObject, codecForStringURL, renderContext } from "./codec.js"; +import { + Codec, + Context, + DecodingError, + buildCodecForObject, + codecForStringURL, + renderContext, +} from "./codec.js"; +import { + AccessToken, + bytesToString, + codecForAccessToken, + codecOptional, + hashTruncate32, + stringToBytes, +} from "./index.js"; import { URLSearchParams } from "./url.js"; export type PaytoUri = @@ -151,15 +166,32 @@ export function addPaytoQueryParams( params: { [name: string]: string }, ): string { const [acct, search] = s.slice(paytoPfx.length).split("?"); - const searchParams = new URLSearchParams(search || ""); - const keys = Object.keys(params); - if (keys.length === 0) { + const paramList = !params ? [] : Object.entries(params); + if (paramList.length === 0) { return paytoPfx + acct; } - for (const k of keys) { - searchParams.set(k, params[k]); - } - return paytoPfx + acct + "?" + searchParams.toString(); + return paytoPfx + acct + "?" + createSearchParams(paramList); +} + +/** + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986 + */ +function encodeRFC3986URIComponent(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} +const rfc3986 = encodeRFC3986URIComponent; + +/** + * + * https://www.rfc-editor.org/rfc/rfc3986 + */ +function createSearchParams(paramList: [string, string][]): string { + return paramList + .map(([key, value]) => `${rfc3986(key)}=${rfc3986(value)}`) + .join("&"); } /** @@ -171,12 +203,15 @@ export function addPaytoQueryParams( export function stringifyPaytoUri(p: PaytoUri): PaytoString { const url = new URL(`${paytoPfx}${p.targetType}/${p.targetPath}`); const paramList = !p.params ? [] : Object.entries(p.params); - paramList.forEach(([key, value]) => { - url.searchParams.set(key, value); - }); + url.search = createSearchParams(paramList); return url.href as PaytoString; } +export function hashPaytoUri(p: PaytoUri): string { + const paytoUri = stringifyPaytoUri(p); + return bytesToString(hashTruncate32(stringToBytes(paytoUri + "\0"))); +} + /** * Parse a valid payto:// uri into a PaytoUri object * RFC 8905 @@ -205,7 +240,8 @@ export function parsePaytoUri(s: string): PaytoUri | undefined { const searchParams = new URLSearchParams(search || ""); searchParams.forEach((v, k) => { - params[k] = v; + // URLSearchParams already decodes uri components + params[k] = v; //decodeURIComponent(v); }); if (targetType === "x-taler-bank") { @@ -294,18 +330,19 @@ export function talerPaytoFromExchangeReserve( /** * The account letter is all the information - * the merchant backend requires from the + * the merchant backend requires from the * bank account to check transfer. - * + * */ export type AccountLetter = { accountURI: PaytoString; infoURL: string; + accountToken?: AccessToken; }; -export const codecForAccountLetter = - (): Codec<AccountLetter> => - buildCodecForObject<AccountLetter>() - .property("infoURL", codecForStringURL(true)) - .property("accountURI", codecForPaytoString()) - .build("AccountLetter"); +export const codecForAccountLetter = (): Codec<AccountLetter> => + buildCodecForObject<AccountLetter>() + .property("infoURL", codecForStringURL(true)) + .property("accountURI", codecForPaytoString()) + .property("accountToken", codecOptional(codecForAccessToken())) + .build("AccountLetter"); diff --git a/packages/taler-util/src/qr.ts b/packages/taler-util/src/qr.ts new file mode 100644 index 000000000..4d90ccf14 --- /dev/null +++ b/packages/taler-util/src/qr.ts @@ -0,0 +1,166 @@ +/* + This file is part of GNU Taler + (C) 2024 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/> + */ + +/** + * Imports. + */ +import { Amounts } from "./index.js"; +import { parsePaytoUri } from "./payto.js"; + +type EncodeResult = { type: "ok"; qrContent: string } | { type: "skip" }; + +/** + * See "Schweizer Implementation Guidelines QR-Rechnung". + */ +function encodePaytoAsSwissQrBill(paytoUri: string): EncodeResult { + const parsedPayto = parsePaytoUri(paytoUri); + if (!parsedPayto) { + throw Error("invalid payto URI"); + } + if (parsedPayto.targetType !== "iban") { + return { type: "skip" }; + } + const amountStr = parsedPayto.params["amount"]; + if (amountStr === undefined) { + return { type: "skip" }; + } + const iban = parsedPayto.targetPath; + const countryCode = iban.slice(0, 2); + const lines = [ + "SPC", // QRType + "0200", // Version + "1", // Character set (1: UTF-8) + iban, // Beneficiary IBAN + // Group: Beneficiary + "S", // Address type (S: structured) + parsedPayto.params["receiver-name"], // Beneficiary name + "", // street + "", // apt. nr. + parsedPayto.params["receiver-postal-code"], // town, // postal code + parsedPayto.params["receiver-town"], // town + countryCode, // Country + // Group: Ultimate Debtor (not used in version 0200) + "", // Ultimate Debtor Address type (S: structured) + "", // Ultimate Debtor name + "", // Ultimate Debtor street + "", // Ultimate Debtor apt. nr + "", // Ultimate Debtor postal code + "", // Ultimate Debtor town + "", // Ultimate Debtor country + // Group: Amount + Amounts.stringifyValue(amountStr, 2), // Amount + Amounts.currencyOf(amountStr), // Currency + // Group: Debtor + "", // Address type (S: structured) + "", // Debtor name + "", // Debtor street + "", // Debtor apt. nr + "", // Debtor postal code + "", // Debtor town + "", // Debtor country + // Group: Reference + "NON", // reference type + "", // Reference + // Group: Additional Information + parsedPayto.params["message"], // Unstructured data + "EPD", // End of payment data + ]; + + return { + type: "ok", + qrContent: lines.join("\n"), + }; +} + +/** + * See "Quick Response Code - Guidelines to + * Enable the Data Capture for the + * Initiation of a SEPA Credit Transfer". + */ +function encodePaytoAsEpcQr(paytoUri: string): EncodeResult { + const parsedPayto = parsePaytoUri(paytoUri); + if (!parsedPayto) { + throw Error("invalid payto URI"); + } + if (parsedPayto.targetType !== "iban") { + return { type: "skip" }; + } + const amountStr = parsedPayto.params["amount"]; + Amounts.stringifyValue; + const lines = [ + "BCD", // service tag + "002", // version + "1", // character set (1: UTF-8) + "SCT", // Identification + "", // optional BIC + parsedPayto.params["receiver-name"], // Beneficiary name + parsedPayto.targetPath, // Beneficiary IBAN + amountStr !== undefined + ? `${Amounts.currencyOf(amountStr)}${Amounts.stringifyValue(amountStr, 2)}` + : "", // Amount (optional) + "", // AT-44 Purpose + parsedPayto.params["message"], // AT-05 Unstructured remittance information + ]; + + return { + type: "ok", + qrContent: lines.join("\n"), + }; +} + +/** + * Specification of a QR code that includes payment information. + */ +export interface QrCodeSpec { + /** + * Type of the QR code. + * + * Depending on the type, different visual styles + * might be applied. + */ + type: string; + + /** + * Content of the QR code that should be rendered. + */ + qrContent: string; +} + +/** + * Get applicable QR code specifications for the given payto URI. + */ +export function getQrCodesForPayto(paytoUri: string): QrCodeSpec[] { + const res: QrCodeSpec[] = []; + { + const qr = encodePaytoAsEpcQr(paytoUri); + if (qr.type == "ok") { + res.push({ + type: "epc-qr", + qrContent: qr.qrContent, + }); + } + } + { + const qr = encodePaytoAsSwissQrBill(paytoUri); + if (qr.type == "ok") { + res.push({ + type: "spc", + qrContent: qr.qrContent, + }); + } + } + return res; +} diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts index c463d94a0..a1b6ccc77 100644 --- a/packages/taler-util/src/taler-error-codes.ts +++ b/packages/taler-util/src/taler-error-codes.ts @@ -2585,6 +2585,14 @@ export enum TalerErrorCode { /** + * Invalid token because it was already used, is expired or not yet valid. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_TOKEN_INVALID = 2183, + + + /** * The contract hash does not match the given order ID. * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). * (A value of 0 indicates that the error is generated client-side). @@ -2945,6 +2953,14 @@ export enum TalerErrorCode { /** + * A token family referenced in this order is either expired or not valid yet. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_NOT_VALID = 2534, + + + /** * The exchange says it does not know this transfer. * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). * (A value of 0 indicates that the error is generated client-side). @@ -4113,6 +4129,22 @@ export enum TalerErrorCode { /** + * A wallet-core request failed because the user needs to first accept the exchange's terms of service. + * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). + * (A value of 0 indicates that the error is generated client-side). + */ + WALLET_EXCHANGE_TOS_NOT_ACCEPTED = 7037, + + + /** + * An exchange entry could not be updated, as the exchange's new details conflict with the new details. + * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). + * (A value of 0 indicates that the error is generated client-side). + */ + WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT = 7038, + + + /** * We encountered a timeout with our payment backend. * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504). * (A value of 0 indicates that the error is generated client-side). diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts index 66f98ea9a..ac42ca278 100644 --- a/packages/taler-util/src/taler-types.ts +++ b/packages/taler-util/src/taler-types.ts @@ -723,6 +723,8 @@ export class ExchangeKeysJson { currency: string; + currency_specification?: CurrencySpecification; + /** * The exchange's master public key. */ @@ -1504,6 +1506,7 @@ export const codecForExchangeKeysJson = (): Codec<ExchangeKeysJson> => buildCodecForObject<ExchangeKeysJson>() .property("base_url", codecForString()) .property("currency", codecForString()) + .property("currency_specification", codecOptional(codecForCurrencySpecificiation())) .property("master_public_key", codecForString()) .property("auditors", codecForList(codecForAuditor())) .property("list_issue_date", codecForTimestamp) diff --git a/packages/taler-util/src/taleruri.test.ts b/packages/taler-util/src/taleruri.test.ts index b92366fb3..d80470dab 100644 --- a/packages/taler-util/src/taleruri.test.ts +++ b/packages/taler-util/src/taleruri.test.ts @@ -54,6 +54,18 @@ test("taler withdraw uri parsing", (t) => { t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/"); }); +test("taler withdraw uri parsing with external confirmation", (t) => { + const url1 = "taler://withdraw/bank.example.com/12345?external-confirmation=1"; + const r1 = parseWithdrawUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.externalConfirmation, true); + t.is(r1.withdrawalOperationId, "12345"); + t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/"); +}); + test("taler withdraw uri parsing (http)", (t) => { const url1 = "taler+http://withdraw/bank.example.com/12345"; const r1 = parseWithdrawUri(url1); diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts index 54b7525e3..d3186d2f5 100644 --- a/packages/taler-util/src/taleruri.ts +++ b/packages/taler-util/src/taleruri.ts @@ -29,6 +29,7 @@ import { opFixedSuccess, opKnownTalerFailure } from "./operation.js"; import { TalerErrorCode } from "./taler-error-codes.js"; import { AmountString } from "./taler-types.js"; import { URL, URLSearchParams } from "./url.js"; + /** * A parsed taler URI. */ @@ -89,6 +90,7 @@ export interface WithdrawUriResult { type: TalerUriAction.Withdraw; bankIntegrationApiBaseUrl: string; withdrawalOperationId: string; + externalConfirmation?: boolean; } export interface RefundUriResult { @@ -140,7 +142,12 @@ export function parseWithdrawUriWithError(s: string) { if (pi.type === "fail") { return pi; } - const parts = pi.body.rest.split("/"); + + const c = pi.body.rest.split("?", 2); + const path = c[0]; + const q = new URLSearchParams(c[1] ?? ""); + + const parts = path.split("/"); if (parts.length < 2) { return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, { @@ -166,6 +173,7 @@ export function parseWithdrawUriWithError(s: string) { `${pi.body.innerProto}://${p}/`, ), withdrawalOperationId: withdrawId, + externalConfirmation: q.get("external-confirmation") == "1", }; return opFixedSuccess(result); } diff --git a/packages/taler-util/src/transactions-types.ts b/packages/taler-util/src/transactions-types.ts index a6ac5aec6..b4e2738ee 100644 --- a/packages/taler-util/src/transactions-types.ts +++ b/packages/taler-util/src/transactions-types.ts @@ -299,6 +299,11 @@ interface WithdrawalDetailsForTalerBankIntegrationApi { */ reserveIsReady: boolean; + /** + * Is the bank transfer for the withdrawal externally confirmed? + */ + externalConfirmation?: boolean; + exchangeCreditAccountDetails?: WithdrawalExchangeAccountDetails[]; } diff --git a/packages/taler-util/src/url.ts b/packages/taler-util/src/url.ts index 149997f3f..1b5626626 100644 --- a/packages/taler-util/src/url.ts +++ b/packages/taler-util/src/url.ts @@ -94,7 +94,7 @@ if (useOwnUrlImp || !_URL) { _URL = URLImpl; } -export const URL: URLCtor = _URL; +export const URL = _URL; // @ts-ignore let _URLSearchParams = globalThis.URLSearchParams; diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index a7aa4f863..ec401f3f6 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -55,6 +55,7 @@ import { } from "./index.js"; import { VersionMatchResult } from "./libtool-version.js"; import { PaytoString, PaytoUri, codecForPaytoString } from "./payto.js"; +import { QrCodeSpec } from "./qr.js"; import { AgeCommitmentProof } from "./taler-crypto.js"; import { TalerErrorCode } from "./taler-error-codes.js"; import { @@ -557,11 +558,13 @@ export enum ScopeType { } export type ScopeInfoGlobal = { type: ScopeType.Global; currency: string }; + export type ScopeInfoExchange = { type: ScopeType.Exchange; currency: string; url: string; }; + export type ScopeInfoAuditor = { type: ScopeType.Auditor; currency: string; @@ -570,6 +573,22 @@ export type ScopeInfoAuditor = { export type ScopeInfo = ScopeInfoGlobal | ScopeInfoExchange | ScopeInfoAuditor; +/** + * Encode scope info as a string. + * + * Format must be stable as it's used in the database. + */ +export function stringifyScopeInfo(si: ScopeInfo): string { + switch (si.type) { + case ScopeType.Global: + return `taler-si:global/${si.currency}}`; + case ScopeType.Auditor: + return `taler-si:auditor/${si.currency}/${encodeURIComponent(si.url)}`; + case ScopeType.Exchange: + return `taler-si:exchange/${si.currency}/${encodeURIComponent(si.url)}`; + } +} + export interface BalancesResponse { balances: WalletBalance[]; } @@ -627,6 +646,32 @@ export enum CoinStatus { Dormant = "dormant", } +export type WalletCoinHistoryItem = + | { + type: "withdraw"; + transactionId: TransactionIdStr; + } + | { + type: "spend"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "refresh"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "recoup"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "refund"; + transactionId: TransactionIdStr; + amount: AmountString; + }; + /** * Easy to process format for the public data of coins * managed by the wallet. @@ -636,44 +681,42 @@ export interface CoinDumpJson { /** * The coin's denomination's public key. */ - denom_pub: DenominationPubKey; + denomPub: DenominationPubKey; /** * Hash of denom_pub. */ - denom_pub_hash: string; + denomPubHash: string; /** * Value of the denomination (without any fees). */ - denom_value: string; + denomValue: string; /** * Public key of the coin. */ - coin_pub: string; + coinPub: string; /** * Base URL of the exchange for the coin. */ - exchange_base_url: string; + exchangeBaseUrl: string; /** * Public key of the parent coin. * Only present if this coin was obtained via refreshing. */ - refresh_parent_coin_pub: string | undefined; + refreshParentCoinPub: string | undefined; /** * Public key of the reserve for this coin. * Only present if this coin was obtained via refreshing. */ - withdrawal_reserve_pub: string | undefined; - coin_status: CoinStatus; - spend_allocation: - | { - id: string; - amount: AmountString; - } - | undefined; + withdrawalReservePub: string | undefined; + /** + * Status of the coin. + */ + coinStatus: CoinStatus; /** * Information about the age restriction */ ageCommitmentProof: AgeCommitmentProof | undefined; + history: WalletCoinHistoryItem[]; }>; } @@ -1338,6 +1381,7 @@ export enum ExchangeTosStatus { Pending = "pending", Proposed = "proposed", Accepted = "accepted", + MissingTos = "missing-tos", } export enum ExchangeEntryStatus { @@ -1396,6 +1440,8 @@ export interface ExchangeListItem { * to update the exchange info. */ lastUpdateErrorInfo?: OperationErrorInfo; + + unavailableReason?: TalerErrorDetail; } const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> => @@ -3371,3 +3417,49 @@ export const codecForHintNetworkAvailabilityRequest = buildCodecForObject<HintNetworkAvailabilityRequest>() .property("isNetworkAvailable", codecForBoolean()) .build("HintNetworkAvailabilityRequest"); + +export interface GetDepositWireTypesForCurrencyRequest { + currency: string; + /** + * Optional scope info to further restrict the result. + * Currency must match the currency field. + */ + scopeInfo?: ScopeInfo; +} + +export const codecForGetDepositWireTypesForCurrencyRequest = + (): Codec<GetDepositWireTypesForCurrencyRequest> => + buildCodecForObject<GetDepositWireTypesForCurrencyRequest>() + .property("currency", codecForString()) + .property("scopeInfo", codecOptional(codecForScopeInfo())) + .build("GetDepositWireTypesForCurrencyRequest"); + +/** + * Response with wire types that are supported for a deposit. + * + * In the future, we might surface more information here, such as debit restrictions + * by the exchange, which then can be shown by UIs to the user before they + * enter their payment information. + */ +export interface GetDepositWireTypesForCurrencyResponse { + wireTypes: string[]; +} + +export interface GetQrCodesForPaytoRequest { + paytoUri: string; +} + +export const codecForGetQrCodesForPaytoRequest = () => + buildCodecForObject<GetQrCodesForPaytoRequest>() + .property("paytoUri", codecForString()) + .build("GetQrCodesForPaytoRequest"); + +export interface GetQrCodesForPaytoResponse { + codes: QrCodeSpec[]; +} + +export type EmptyObject = Record<string, never>; + +export const codecForEmptyObject = (): Codec<EmptyObject> => + buildCodecForObject<EmptyObject>() + .build("EmptyObject");
\ No newline at end of file diff --git a/packages/taler-wallet-cli/debian/changelog b/packages/taler-wallet-cli/debian/changelog index 5fa99e801..7ef8445dc 100644 --- a/packages/taler-wallet-cli/debian/changelog +++ b/packages/taler-wallet-cli/debian/changelog @@ -1,3 +1,21 @@ +taler-wallet-cli (0.12.2) unstable; urgency=low + + * Release 0.12.2 + + -- Florian Dold <dold@taler.net> Thu, 27 Jun 2024 20:19:19 +0200 + +taler-wallet-cli (0.12.1) unstable; urgency=low + + * Release 0.12.1 + + -- Florian Dold <dold@taler.net> Wed, 26 Jun 2024 09:30:32 -0600 + +taler-wallet-cli (0.12.0) unstable; urgency=low + + * Release 0.12.0 + + -- Florian Dold <dold@taler.net> Wed, 26 Jun 2024 16:17:52 +0200 + taler-wallet-cli (0.11.4) unstable; urgency=low * Release 0.11.4 diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json index ecc8252e6..3430d525d 100644 --- a/packages/taler-wallet-cli/package.json +++ b/packages/taler-wallet-cli/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-cli", - "version": "0.11.4", + "version": "0.12.2", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index 5bde7db01..be74e464b 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -21,6 +21,7 @@ import { AbsoluteTime, addPaytoQueryParams, AgeRestriction, + Amounts, AmountString, codecForList, codecForString, @@ -722,6 +723,16 @@ walletCli }, ); console.log("withdrawInfo", withdrawInfo); + let amount: AmountString | undefined = undefined; + if (withdrawInfo.editableAmount) { + if (withdrawInfo.amount) { + console.log(`Default amount: ${withdrawInfo.amount}`); + } + const res = await readlinePrompt( + `Amount (in ${withdrawInfo.currency}): `, + ); + amount = Amounts.stringify(Amounts.parseOrThrow(res)); + } const selectedExchange = args.handleUri.withdrawalExchange ?? withdrawInfo.defaultExchangeBaseUrl; @@ -732,6 +743,10 @@ walletCli processExit(1); return; } + // FIXME: Maybe prompt for this? + await wallet.client.call(WalletApiOperation.SetExchangeTosAccepted, { + exchangeBaseUrl: selectedExchange, + }); const res = await wallet.client.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, { @@ -1365,13 +1380,8 @@ advancedCli advancedCli .subcommand("pending", "pending", { help: "Show pending operations." }) .action(async (args) => { - await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { - const pending = await wallet.client.call( - WalletApiOperation.GetPendingOperations, - {}, - ); - console.log(JSON.stringify(pending, undefined, 2)); - }); + console.error("Subcommand removed due to deprecation."); + process.exit(1); }); advancedCli @@ -1695,10 +1705,25 @@ advancedCli await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { const coins = await wallet.client.call(WalletApiOperation.DumpCoins, {}); for (const coin of coins.coins) { - console.log(`coin ${coin.coin_pub}`); - console.log(` exchange ${coin.exchange_base_url}`); - console.log(` denomPubHash ${coin.denom_pub_hash}`); - console.log(` status ${coin.coin_status}`); + console.log(`coin ${coin.coinPub}`); + console.log(` exchange ${coin.exchangeBaseUrl}`); + console.log(` denomPubHash ${coin.denomPubHash}`); + console.log(` status ${coin.coinStatus}`); + if (coin.history.length > 0) { + console.log(` history`); + for (const hi of coin.history) { + switch (hi.type) { + case "spend": + console.log(` spend ${hi.transactionId} ${hi.amount}`); + break; + case "refresh": + console.log(` refresh ${hi.transactionId} ${hi.amount}`); + break; + default: + console.log(` unknown (${hi.type})`); + } + } + } } }); }); diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index c710861d3..273ad75f6 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-core", - "version": "0.11.4", + "version": "0.12.2", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts index 09d5ae75d..c5febd278 100644 --- a/packages/taler-wallet-core/src/backup/index.ts +++ b/packages/taler-wallet-core/src/backup/index.ts @@ -427,6 +427,10 @@ export async function processBackupForProvider( wex: WalletExecutionContext, backupProviderBaseUrl: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + const provider = await wex.db.runReadOnlyTx( { storeNames: ["backupProviders"] }, async (tx) => { diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts index db6384c93..51316a21f 100644 --- a/packages/taler-wallet-core/src/coinSelection.ts +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -252,6 +252,88 @@ async function internalSelectPayCoins( }; } +export async function selectPayCoinsInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "coinAvailability", + "denominations", + "refreshGroups", + "exchanges", + "exchangeDetails", + "coins", + ] + >, + req: SelectPayCoinRequestNg, +): Promise<SelectPayCoinsResult> { + if (logger.shouldLogTrace()) { + logger.trace(`selecting coins for ${j2s(req)}`); + } + + const materialAvSel = await internalSelectPayCoins(wex, tx, req, false); + + if (!materialAvSel) { + const prospectiveAvSel = await internalSelectPayCoins(wex, tx, req, true); + + if (prospectiveAvSel) { + const prospectiveCoins: SelectedProspectiveCoin[] = []; + for (const avKey of Object.keys(prospectiveAvSel.sel)) { + const mySel = prospectiveAvSel.sel[avKey]; + for (const contrib of mySel.contributions) { + prospectiveCoins.push({ + denomPubHash: mySel.denomPubHash, + contribution: Amounts.stringify(contrib), + exchangeBaseUrl: mySel.exchangeBaseUrl, + }); + } + } + return { + type: "prospective", + result: { + prospectiveCoins, + customerDepositFees: Amounts.stringify( + prospectiveAvSel.tally.customerDepositFees, + ), + customerWireFees: Amounts.stringify( + prospectiveAvSel.tally.customerWireFees, + ), + }, + } satisfies SelectPayCoinsResult; + } + + return { + type: "failure", + insufficientBalanceDetails: await reportInsufficientBalanceDetails( + wex, + tx, + { + restrictExchanges: req.restrictExchanges, + instructedAmount: req.contractTermsAmount, + requiredMinimumAge: req.requiredMinimumAge, + wireMethod: req.restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + }, + ), + } satisfies SelectPayCoinsResult; + } + + const coinSel = await assembleSelectPayCoinsSuccessResult( + tx, + materialAvSel.sel, + materialAvSel.coinRes, + materialAvSel.tally, + ); + + if (logger.shouldLogTrace()) { + logger.trace(`coin selection: ${j2s(coinSel)}`); + } + + return { + type: "success", + coinSel, + }; +} + /** * Select coins to spend under the merchant's constraints. * @@ -263,10 +345,6 @@ export async function selectPayCoins( wex: WalletExecutionContext, req: SelectPayCoinRequestNg, ): Promise<SelectPayCoinsResult> { - if (logger.shouldLogTrace()) { - logger.trace(`selecting coins for ${j2s(req)}`); - } - return await wex.db.runReadOnlyTx( { storeNames: [ @@ -279,73 +357,7 @@ export async function selectPayCoins( ], }, async (tx) => { - const materialAvSel = await internalSelectPayCoins(wex, tx, req, false); - - if (!materialAvSel) { - const prospectiveAvSel = await internalSelectPayCoins( - wex, - tx, - req, - true, - ); - - if (prospectiveAvSel) { - const prospectiveCoins: SelectedProspectiveCoin[] = []; - for (const avKey of Object.keys(prospectiveAvSel.sel)) { - const mySel = prospectiveAvSel.sel[avKey]; - for (const contrib of mySel.contributions) { - prospectiveCoins.push({ - denomPubHash: mySel.denomPubHash, - contribution: Amounts.stringify(contrib), - exchangeBaseUrl: mySel.exchangeBaseUrl, - }); - } - } - return { - type: "prospective", - result: { - prospectiveCoins, - customerDepositFees: Amounts.stringify( - prospectiveAvSel.tally.customerDepositFees, - ), - customerWireFees: Amounts.stringify( - prospectiveAvSel.tally.customerWireFees, - ), - }, - } satisfies SelectPayCoinsResult; - } - - return { - type: "failure", - insufficientBalanceDetails: await reportInsufficientBalanceDetails( - wex, - tx, - { - restrictExchanges: req.restrictExchanges, - instructedAmount: req.contractTermsAmount, - requiredMinimumAge: req.requiredMinimumAge, - wireMethod: req.restrictWireMethod, - depositPaytoUri: req.depositPaytoUri, - }, - ), - } satisfies SelectPayCoinsResult; - } - - const coinSel = await assembleSelectPayCoinsSuccessResult( - tx, - materialAvSel.sel, - materialAvSel.coinRes, - materialAvSel.tally, - ); - - if (logger.shouldLogTrace()) { - logger.trace(`coin selection: ${j2s(coinSel)}`); - } - - return { - type: "success", - coinSel, - }; + return selectPayCoinsInTx(wex, tx, req); }, ); } @@ -910,7 +922,10 @@ async function selectPayCandidates( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`, + ); if (denom.isRevoked) { logger.trace("denom is revoked"); continue; @@ -1131,17 +1146,127 @@ async function internalSelectPeerCoins( }; } -export async function selectPeerCoins( +export async function selectPeerCoinsInTx( wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "exchanges", + "contractTerms", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "exchangeDetails", + ] + >, req: PeerCoinSelectionRequest, ): Promise<SelectPeerCoinsResult> { const instructedAmount = req.instructedAmount; if (Amounts.isZero(instructedAmount)) { // Other parts of the code assume that we have at least // one coin to spend. - throw new Error("amount of zero not allowed"); + throw new Error("peer-to-peer payment with amount of zero not supported"); } + const exchanges = await tx.exchanges.iter().toArray(); + const currency = Amounts.currencyOf(instructedAmount); + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; + } + const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); + if (!exchWire) { + continue; + } + const globalFees = getGlobalFees(exchWire); + if (!globalFees) { + continue; + } + + const avRes = await internalSelectPeerCoins(wex, tx, req, exchWire, false); + + if (!avRes) { + // Try to see if we can do a prospective selection + const prospectiveAvRes = await internalSelectPeerCoins( + wex, + tx, + req, + exchWire, + true, + ); + if (prospectiveAvRes) { + const prospectiveCoins: SelectedProspectiveCoin[] = []; + for (const avKey of Object.keys(prospectiveAvRes.sel)) { + const mySel = prospectiveAvRes.sel[avKey]; + for (const contrib of mySel.contributions) { + prospectiveCoins.push({ + denomPubHash: mySel.denomPubHash, + contribution: Amounts.stringify(contrib), + exchangeBaseUrl: mySel.exchangeBaseUrl, + }); + } + } + const maxExpirationDate = await computeCoinSelMaxExpirationDate( + wex, + tx, + prospectiveAvRes.sel, + ); + return { + type: "prospective", + result: { + prospectiveCoins, + depositFees: prospectiveAvRes.tally.customerDepositFees, + exchangeBaseUrl: exch.baseUrl, + maxExpirationDate, + }, + }; + } + } else if (avRes) { + const r = await assembleSelectPayCoinsSuccessResult( + tx, + avRes.sel, + avRes.resCoins, + avRes.tally, + ); + + const maxExpirationDate = await computeCoinSelMaxExpirationDate( + wex, + tx, + avRes.sel, + ); + + return { + type: "success", + result: { + coins: r.coins, + depositFees: Amounts.parseOrThrow(r.customerDepositFees), + exchangeBaseUrl: exch.baseUrl, + maxExpirationDate, + }, + }; + } + } + const insufficientBalanceDetails = await reportInsufficientBalanceDetails( + wex, + tx, + { + restrictExchanges: undefined, + instructedAmount: req.instructedAmount, + requiredMinimumAge: undefined, + wireMethod: undefined, + depositPaytoUri: undefined, + }, + ); + return { + type: "failure", + insufficientBalanceDetails, + }; +} + +export async function selectPeerCoins( + wex: WalletExecutionContext, + req: PeerCoinSelectionRequest, +): Promise<SelectPeerCoinsResult> { return await wex.db.runReadWriteTx( { storeNames: [ @@ -1155,105 +1280,7 @@ export async function selectPeerCoins( ], }, async (tx): Promise<SelectPeerCoinsResult> => { - const exchanges = await tx.exchanges.iter().toArray(); - const currency = Amounts.currencyOf(instructedAmount); - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); - if (!exchWire) { - continue; - } - const globalFees = getGlobalFees(exchWire); - if (!globalFees) { - continue; - } - - const avRes = await internalSelectPeerCoins( - wex, - tx, - req, - exchWire, - false, - ); - - if (!avRes) { - // Try to see if we can do a prospective selection - const prospectiveAvRes = await internalSelectPeerCoins( - wex, - tx, - req, - exchWire, - true, - ); - if (prospectiveAvRes) { - const prospectiveCoins: SelectedProspectiveCoin[] = []; - for (const avKey of Object.keys(prospectiveAvRes.sel)) { - const mySel = prospectiveAvRes.sel[avKey]; - for (const contrib of mySel.contributions) { - prospectiveCoins.push({ - denomPubHash: mySel.denomPubHash, - contribution: Amounts.stringify(contrib), - exchangeBaseUrl: mySel.exchangeBaseUrl, - }); - } - } - const maxExpirationDate = await computeCoinSelMaxExpirationDate( - wex, - tx, - prospectiveAvRes.sel, - ); - return { - type: "prospective", - result: { - prospectiveCoins, - depositFees: prospectiveAvRes.tally.customerDepositFees, - exchangeBaseUrl: exch.baseUrl, - maxExpirationDate, - }, - }; - } - } else if (avRes) { - const r = await assembleSelectPayCoinsSuccessResult( - tx, - avRes.sel, - avRes.resCoins, - avRes.tally, - ); - - const maxExpirationDate = await computeCoinSelMaxExpirationDate( - wex, - tx, - avRes.sel, - ); - - return { - type: "success", - result: { - coins: r.coins, - depositFees: Amounts.parseOrThrow(r.customerDepositFees), - exchangeBaseUrl: exch.baseUrl, - maxExpirationDate, - }, - }; - } - } - const insufficientBalanceDetails = await reportInsufficientBalanceDetails( - wex, - tx, - { - restrictExchanges: undefined, - instructedAmount: req.instructedAmount, - requiredMinimumAge: undefined, - wireMethod: undefined, - depositPaytoUri: undefined, - }, - ); - return { - type: "failure", - insufficientBalanceDetails, - }; + return selectPeerCoinsInTx(wex, tx, req); }, ); } diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts index a20278cf3..c37d0e21a 100644 --- a/packages/taler-wallet-core/src/common.ts +++ b/packages/taler-wallet-core/src/common.ts @@ -31,6 +31,8 @@ import { ExchangeUpdateStatus, Logger, RefreshReason, + TalerError, + TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, @@ -41,10 +43,10 @@ import { checkDbInvariant, checkLogicInvariant, durationMul, - j2s, } from "@gnu-taler/taler-util"; import { BackupProviderRecord, + CoinHistoryRecord, CoinRecord, DbPreciseTimestamp, DepositGroupRecord, @@ -62,6 +64,7 @@ import { WithdrawalGroupRecord, timestampPreciseToDb, } from "./db.js"; +import { ReadyExchangeSummary } from "./exchanges.js"; import { createRefreshGroup } from "./refresh.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; @@ -72,9 +75,9 @@ export interface CoinsSpendInfo { contributions: AmountJson[]; refreshReason: RefreshReason; /** - * Identifier for what the coin has been spent for. + * Transaction for which the coin is spent. */ - allocationId: TransactionIdStr; + transactionId: TransactionIdStr; } export async function makeCoinsVisible( @@ -121,7 +124,10 @@ export async function makeCoinAvailable( coinRecord.exchangeBaseUrl, coinRecord.denomPubHash, ]); - checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinRecord.denomPubHash}`); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coinRecord.denomPubHash}`, + ); const ageRestriction = coinRecord.maxAge; let car = await tx.coinAvailability.get([ coinRecord.exchangeBaseUrl, @@ -144,11 +150,16 @@ export async function makeCoinAvailable( await tx.coinAvailability.put(car); } +/** + * Spend coins. Marks the coins as used, adds a coin history items + * and creates refresh group. + */ export async function spendCoins( wex: WalletExecutionContext, tx: WalletDbReadWriteTransaction< [ "coins", + "coinHistory", "coinAvailability", "refreshGroups", "refreshSessions", @@ -175,36 +186,21 @@ export async function spendCoins( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coin.denomPubHash}`); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coin.denomPubHash}`, + ); const coinAvailability = await tx.coinAvailability.get([ coin.exchangeBaseUrl, coin.denomPubHash, coin.maxAge, ]); - checkDbInvariant(!!coinAvailability, `age denom info is missing for ${coin.maxAge}`); + checkDbInvariant( + !!coinAvailability, + `age denom info is missing for ${coin.maxAge}`, + ); const contrib = csi.contributions[i]; - if (coin.status !== CoinStatus.Fresh) { - const alloc = coin.spendAllocation; - if (!alloc) { - continue; - } - if (alloc.id !== csi.allocationId) { - // FIXME: assign error code - logger.info("conflicting coin allocation ID"); - logger.info(`old ID: ${alloc.id}, new ID: ${csi.allocationId}`); - throw Error("conflicting coin allocation (id)"); - } - if (0 !== Amounts.cmp(alloc.amount, contrib)) { - // FIXME: assign error code - throw Error("conflicting coin allocation (contrib)"); - } - continue; - } coin.status = CoinStatus.Dormant; - coin.spendAllocation = { - id: csi.allocationId, - amount: Amounts.stringify(contrib), - }; const remaining = Amounts.sub(denom.value, contrib); if (remaining.saturated) { throw Error("not enough remaining balance on coin for payment"); @@ -226,6 +222,21 @@ export async function spendCoins( coinAvailability.visibleCoinCount--; } } + let histEntry: CoinHistoryRecord | undefined = await tx.coinHistory.get( + coin.coinPub, + ); + if (!histEntry) { + histEntry = { + coinPub: coin.coinPub, + history: [], + }; + } + histEntry.history.push({ + type: "spend", + transactionId: csi.transactionId, + amount: Amounts.stringify(contrib), + }); + await tx.coinHistory.put(histEntry); await tx.coins.put(coin); await tx.coinAvailability.put(coinAvailability); } @@ -236,7 +247,7 @@ export async function spendCoins( Amounts.currencyOf(csi.contributions[0]), refreshCoinPubs, csi.refreshReason, - csi.allocationId, + csi.transactionId, ); } @@ -257,6 +268,9 @@ export enum TombstoneTag { export function getExchangeTosStatusFromRecord( exchange: ExchangeEntryRecord, ): ExchangeTosStatus { + if (exchange.tosCurrentEtag == null) { + return ExchangeTosStatus.MissingTos; + } if (!exchange.tosAcceptedEtag) { return ExchangeTosStatus.Proposed; } @@ -364,6 +378,7 @@ export enum TaskRunResultType { Error = "error", LongpollReturnedPending = "longpoll-returned-pending", ScheduleLater = "schedule-later", + NetworkRequired = "network-required", } export type TaskRunResult = @@ -372,7 +387,8 @@ export type TaskRunResult = | TaskRunBackoffResult | TaskRunProgressResult | TaskRunLongpollReturnedPendingResult - | TaskRunScheduleLaterResult; + | TaskRunScheduleLaterResult + | TaskRunNetworkRequiredResult; export namespace TaskRunResult { /** @@ -419,6 +435,15 @@ export namespace TaskRunResult { type: TaskRunResultType.LongpollReturnedPending, }; } + /** + * Network connection is required to complete the task. + * When network is back, the transaction will be retried. + */ + export function networkRequired(): TaskRunResult { + return { + type: TaskRunResultType.NetworkRequired, + }; + } } export interface TaskRunFinishedResult { @@ -442,6 +467,10 @@ export interface TaskRunLongpollReturnedPendingResult { type: TaskRunResultType.LongpollReturnedPending; } +export interface TaskRunNetworkRequiredResult { + type: TaskRunResultType.NetworkRequired; +} + export interface TaskRunErrorResult { type: TaskRunResultType.Error; errorDetail: TalerErrorDetail; @@ -818,3 +847,22 @@ export async function genericWaitForState( throw e; } } + +export function requireExchangeTosAcceptedOrThrow( + exchange: ReadyExchangeSummary, +): void { + switch (exchange.tosStatus) { + case ExchangeTosStatus.Accepted: + case ExchangeTosStatus.MissingTos: + break; + default: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED, + { + exchangeBaseUrl: exchange.exchangeBaseUrl, + currentEtag: exchange.tosCurrentEtag, + tosStatus: exchange.tosStatus, + }, + ); + } +} diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 5c381eea7..138db157e 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -40,6 +40,7 @@ import { CoinPublicKeyString, CoinRefreshRequest, CoinStatus, + CurrencySpecification, DenomLossEventType, DenomSelectionState, DenominationInfo, @@ -51,6 +52,7 @@ import { HashCodeString, Logger, RefreshReason, + ScopeInfo, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolDuration, @@ -61,6 +63,7 @@ import { WireInfo, WithdrawalExchangeAccountDetails, codecForAny, + stringifyScopeInfo, } from "@gnu-taler/taler-util"; import { DbRetryInfo, TaskIdentifiers } from "./common.js"; import { @@ -151,7 +154,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 10; +export const WALLET_DB_MINOR_VERSION = 12; declare const symDbProtocolTimestamp: unique symbol; @@ -400,6 +403,8 @@ export interface ReserveBankInfo { wireTypes: string[] | undefined; currency: string | undefined; + + externalConfirmation?: boolean; } /** @@ -677,6 +682,8 @@ export interface ExchangeEntryRecord { updateStatus: ExchangeEntryDbUpdateStatus; + unavailableReason?: TalerErrorDetail; + /** * If set to true, the next update to the exchange * status will request /keys with no-cache headers set. @@ -894,15 +901,6 @@ export interface CoinRecord { visible?: number; /** - * Information about what the coin has been allocated for. - * - * Used for: - * - Diagnostics - * - Idempotency of applying a coin selection (e.g. after re-selection) - */ - spendAllocation: CoinAllocation | undefined; - - /** * Maximum age of purchases that can be made with this coin. * * (Used for indexing, redundant with {@link ageCommitmentProof}). @@ -913,14 +911,50 @@ export interface CoinRecord { } /** - * Coin allocation, i.e. what a coin has been used for. + * History item for a coin. + * + * DB-specific format, */ -export interface CoinAllocation { +export type DbWalletCoinHistoryItem = + | { + type: "withdraw"; + transactionId: TransactionIdStr; + } + | { + type: "spend"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "refresh"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "recoup"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "refund"; + transactionId: TransactionIdStr; + amount: AmountString; + }; + +/** + * History event for a coin from the wallet's perspective. + * + * The history might reference transactions that were already deleted from the wallet. + */ +export interface CoinHistoryRecord { + coinPub: string; /** - * ID of the allocation, should be the ID of the transaction that + * History items for the coin. + * + * We store this as an array in the object store, as the coin history + * is pretty much always very small. */ - id: TransactionIdStr; - amount: AmountString; + history: DbWalletCoinHistoryItem[]; } export enum RefreshCoinStatus { @@ -2339,6 +2373,23 @@ export interface DenomLossEventRecord { exchangeBaseUrl: string; } +export interface CurrencyInfoRecord { + /** + * Stringified scope info. + */ + scopeInfoStr: string; + + /** + * Currency specification. + */ + currencySpec: CurrencySpecification; + + /** + * How did the currency info get set? + */ + source: "exchange" | "user" | "preset"; +} + /** * Schema definition for the IndexedDB * wallet database. @@ -2373,6 +2424,12 @@ export const WalletStoresV1 = { }), }, }), + currencyInfo: describeStoreV2({ + recordCodec: passthroughCodec<CurrencyInfoRecord>(), + storeName: "currencyInfo", + keyPath: "scopeInfoStr", + versionAdded: 12, + }), globalCurrencyAuditors: describeStoreV2({ recordCodec: passthroughCodec<GlobalCurrencyAuditorRecord>(), storeName: "globalCurrencyAuditors", @@ -2423,6 +2480,12 @@ export const WalletStoresV1 = { }), }, ), + coinHistory: describeStoreV2({ + storeName: "coinHistory", + recordCodec: passthroughCodec<CoinHistoryRecord>(), + keyPath: "coinPub", + versionAdded: 11, + }), coins: describeStore( "coins", describeContents<CoinRecord>({ @@ -3325,3 +3388,75 @@ export async function deleteTalerDatabase( req.onsuccess = () => resolve(); }); } + +/** + * High-level helpers to access the database. + * Eventually all access to the database should + * go through helpers in this namespace. + */ +export namespace WalletDbHelpers { + export interface GetCurrencyInfoDbResult { + /** + * Currency specification. + */ + currencySpec: CurrencySpecification; + + /** + * How did the currency info get set? + */ + source: "exchange" | "user" | "preset"; + } + + export interface StoreCurrencyInfoDbRequest { + scopeInfo: ScopeInfo; + currencySpec: CurrencySpecification; + source: "exchange" | "user" | "preset"; + } + + export async function getCurrencyInfo( + tx: WalletDbReadOnlyTransaction<["currencyInfo"]>, + scopeInfo: ScopeInfo, + ): Promise<GetCurrencyInfoDbResult | undefined> { + const s = stringifyScopeInfo(scopeInfo); + const res = await tx.currencyInfo.get(s); + if (!res) { + return undefined; + } + return { + currencySpec: res.currencySpec, + source: res.source, + }; + } + + /** + * Store currency info for a scope. + * + * Overrides existing currency infos. + */ + export async function upsertCurrencyInfo( + tx: WalletDbReadWriteTransaction<["currencyInfo"]>, + req: StoreCurrencyInfoDbRequest, + ): Promise<void> { + await tx.currencyInfo.put({ + scopeInfoStr: stringifyScopeInfo(req.scopeInfo), + currencySpec: req.currencySpec, + source: req.source, + }); + } + + export async function insertCurrencyInfoUnlessExists( + tx: WalletDbReadWriteTransaction<["currencyInfo"]>, + req: StoreCurrencyInfoDbRequest, + ): Promise<void> { + const scopeInfoStr = stringifyScopeInfo(req.scopeInfo); + const oldRec = await tx.currencyInfo.get(scopeInfoStr); + if (oldRec) { + return; + } + await tx.currencyInfo.put({ + scopeInfoStr: stringifyScopeInfo(req.scopeInfo), + currencySpec: req.currencySpec, + source: req.source, + }); + } +} diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index ec9655e6f..bc0b6e428 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -56,9 +56,9 @@ import { } from "@gnu-taler/taler-util/http"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { DenominationRecord } from "./db.js"; +import { isWithdrawableDenom } from "./denominations.js"; import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js"; import { assembleRefreshRevealRequest } from "./refresh.js"; -import { isWithdrawableDenom } from "./denominations.js"; import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js"; export { downloadExchangeInfo }; @@ -85,26 +85,6 @@ export interface CoinInfo { maxAge: number; } -/** - * Check the status of a reserve, use long-polling to wait - * until the reserve actually has been created. - */ -export async function checkReserve( - http: HttpRequestLibrary, - exchangeBaseUrl: string, - reservePub: string, - longpollTimeoutMs: number = 500, -): Promise<void> { - const reqUrl = new URL(`reserves/${reservePub}`, exchangeBaseUrl); - if (longpollTimeoutMs) { - reqUrl.searchParams.set("timeout_ms", `${longpollTimeoutMs}`); - } - const resp = await http.fetch(reqUrl.href, { method: "GET" }); - if (resp.status !== 200) { - throw new Error("reserve not okay"); - } -} - export interface TopupReserveWithBankArgs { http: HttpRequestLibrary; reservePub: string; @@ -415,3 +395,25 @@ export async function createTestingReserve(args: { ); await readSuccessResponseJsonOrThrow(fbReq, codecForAny()); } + +/** + * Check the status of a reserve, use long-polling to wait + * until the reserve actually has been created. + */ +export async function checkReserve( + http: HttpRequestLibrary, + exchangeBaseUrl: string, + reservePub: string, + longpollTimeoutMs: number = 500, +): Promise<void> { + const reqUrl = new URL(`reserves/${reservePub}`, exchangeBaseUrl); + if (longpollTimeoutMs) { + reqUrl.searchParams.set("timeout_ms", `${longpollTimeoutMs}`); + } + const resp = await http.fetch(reqUrl.href, { + method: "GET", + }); + if (resp.status !== 200) { + throw new Error("reserve not okay"); + } +} diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index 2004c12cb..36ebf3974 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -72,7 +72,7 @@ import { stringToBytes, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { selectPayCoins } from "./coinSelection.js"; +import { selectPayCoins, selectPayCoinsInTx } from "./coinSelection.js"; import { PendingTaskType, TaskIdStr, @@ -518,12 +518,13 @@ async function refundDepositGroup( const res = await wex.db.runReadWriteTx( { storeNames: [ + "coinAvailability", + "coinHistory", + "coins", + "denominations", "depositGroups", "refreshGroups", "refreshSessions", - "coins", - "denominations", - "coinAvailability", ], }, async (tx) => { @@ -668,12 +669,20 @@ async function processDepositGroupPendingKyc( `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, kycInfo.exchangeBaseUrl, ); - url.searchParams.set("timeout_ms", "10000"); - logger.info(`kyc url ${url.href}`); - const kycStatusRes = await wex.http.fetch(url.href, { - method: "GET", - cancellationToken: wex.cancellationToken, - }); + + const kycStatusRes = await wex.ws.runLongpollQueueing( + wex, + url.hostname, + async (timeoutMs) => { + url.searchParams.set("timeout_ms", `${timeoutMs}`); + logger.info(`kyc url ${url.href}`); + return await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + }, + ); + if ( kycStatusRes.status === HttpStatusCode.Ok || //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge @@ -979,43 +988,17 @@ async function processDepositGroupPendingDeposit( if (!depositGroup.payCoinSelection) { logger.info("missing coin selection for deposit group, selecting now"); - // FIXME: Consider doing the coin selection inside the txn - const payCoinSel = await selectPayCoins(wex, { - restrictExchanges: { - auditors: [], - exchanges: contractData.allowedExchanges, - }, - restrictWireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - prevPayCoins: [], - }); - - switch (payCoinSel.type) { - case "success": - logger.info("coin selection success"); - break; - case "failure": - logger.info("coin selection failure"); - throw TalerError.fromDetail( - TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, - }, - ); - case "prospective": - logger.info("coin selection prospective"); - throw Error("insufficient balance (waiting on pending refresh)"); - default: - assertUnreachable(payCoinSel); - } const transitionDone = await wex.db.runReadWriteTx( { storeNames: [ + "contractTerms", + "exchanges", + "exchangeDetails", "depositGroups", "coins", "coinAvailability", + "coinHistory", "refreshGroups", "refreshSessions", "denominations", @@ -1029,6 +1012,45 @@ async function processDepositGroupPendingDeposit( if (dg.statusPerCoin) { return false; } + + const contractTermsRec = tx.contractTerms.get( + depositGroup.contractTermsHash, + ); + if (!contractTermsRec) { + throw Error("contract terms for deposit not found in database"); + } + + const payCoinSel = await selectPayCoinsInTx(wex, tx, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins: [], + }); + + switch (payCoinSel.type) { + case "success": + logger.info("coin selection success"); + break; + case "failure": + logger.info("coin selection failure"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: + payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + logger.info("coin selection prospective"); + throw Error("insufficient balance (waiting on pending refresh)"); + default: + assertUnreachable(payCoinSel); + } + dg.payCoinSelection = { coinContributions: payCoinSel.coinSel.coins.map( (x) => x.contribution, @@ -1041,7 +1063,7 @@ async function processDepositGroupPendingDeposit( ); await tx.depositGroups.put(dg); await spendCoins(wex, tx, { - allocationId: transactionId, + transactionId, coinPubs: dg.payCoinSelection.coinPubs, contributions: dg.payCoinSelection.coinContributions.map((x) => Amounts.parseOrThrow(x), @@ -1168,6 +1190,10 @@ export async function processDepositGroup( wex: WalletExecutionContext, depositGroupId: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + const depositGroup = await wex.db.runReadOnlyTx( { storeNames: ["depositGroups"] }, async (tx) => { @@ -1264,11 +1290,17 @@ async function trackDeposit( wireHash, }); url.searchParams.set("merchant_sig", sigResp.sig); - url.searchParams.set("timeout_ms", "30000"); - const httpResp = await wex.http.fetch(url.href, { - method: "GET", - cancellationToken: wex.cancellationToken, - }); + const httpResp = await wex.ws.runLongpollQueueing( + wex, + url.hostname, + async (timeoutMs) => { + url.searchParams.set("timeout_ms", `${timeoutMs}`); + return await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + }, + ); logger.trace(`deposits response status: ${httpResp.status}`); switch (httpResp.status) { case HttpStatusCode.Accepted: { @@ -1606,20 +1638,21 @@ export async function createDepositGroup( const newTxState = await wex.db.runReadWriteTx( { storeNames: [ - "depositGroups", + "coinAvailability", + "coinHistory", "coins", - "recoupGroups", + "contractTerms", "denominations", + "depositGroups", + "recoupGroups", "refreshGroups", "refreshSessions", - "coinAvailability", - "contractTerms", ], }, async (tx) => { if (depositGroup.payCoinSelection) { await spendCoins(wex, tx, { - allocationId: transactionId, + transactionId, coinPubs: depositGroup.payCoinSelection.coinPubs, contributions: depositGroup.payCoinSelection.coinContributions.map( (x) => Amounts.parseOrThrow(x), diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index adb696de0..04f4b27eb 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -28,10 +28,10 @@ import { AgeRestriction, Amount, Amounts, - AsyncFlag, CancellationToken, CoinRefreshRequest, CoinStatus, + CurrencySpecification, DeleteExchangeRequest, DenomKeyType, DenomLossEventType, @@ -46,6 +46,7 @@ import { ExchangeListItem, ExchangeSignKeyJson, ExchangeTosStatus, + ExchangeUpdateStatus, ExchangeWireAccount, ExchangesListResponse, FeeDescription, @@ -53,6 +54,7 @@ import { GetExchangeResourcesResponse, GetExchangeTosResult, GlobalFees, + HttpStatusCode, LibtoolVersion, Logger, NotificationType, @@ -79,6 +81,7 @@ import { WireInfo, assertUnreachable, checkDbInvariant, + checkLogicInvariant, codecForExchangeKeysJson, durationMul, encodeCrock, @@ -86,6 +89,7 @@ import { hashDenomPub, j2s, makeErrorDetail, + makeTalerErrorDetail, parsePaytoUri, } from "@gnu-taler/taler-util"; import { @@ -93,6 +97,8 @@ import { getExpiry, readSuccessResponseJsonOrThrow, readSuccessResponseTextOrThrow, + readTalerErrorResponse, + throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { PendingTaskType, @@ -103,6 +109,7 @@ import { TransactionContext, computeDbBackoff, constructTaskIdentifier, + genericWaitForState, getAutoRefreshExecuteThreshold, getExchangeEntryStatusFromRecord, getExchangeState, @@ -118,6 +125,7 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, ExchangeEntryRecord, + WalletDbHelpers, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, WalletStoresV1, @@ -187,7 +195,7 @@ async function downloadExchangeWithTermsOfService( cancellationToken: wex.cancellationToken, }); const tosText = await readSuccessResponseTextOrThrow(resp); - const tosEtag = resp.headers.get("etag") || "unknown"; + const tosEtag = resp.headers.get("taler-terms-version") || "unknown"; const tosContentLanguage = resp.headers.get("content-language") || undefined; const tosContentType = resp.headers.get("content-type") || "text/plain"; const availLangStr = resp.headers.get("avail-languages") || ""; @@ -321,7 +329,7 @@ async function makeExchangeListItem( scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); } - return { + const listItem: ExchangeListItem = { exchangeBaseUrl: r.baseUrl, masterPub: exchangeDetails?.masterPublicKey, noFees: r.noFees ?? false, @@ -342,6 +350,14 @@ async function makeExchangeListItem( url: r.baseUrl, }, }; + switch (listItem.exchangeUpdateStatus) { + case ExchangeUpdateStatus.UnavailableUpdate: + if (r.unavailableReason) { + listItem.unavailableReason = r.unavailableReason; + } + break; + } + return listItem; } export interface ExchangeWireDetails { @@ -696,6 +712,7 @@ export interface ExchangeKeysDownloadResult { globalFees: GlobalFees[]; accounts: ExchangeWireAccount[]; wireFees: { [methodName: string]: WireFeesJson[] }; + currencySpecification?: CurrencySpecification; } /** @@ -858,6 +875,42 @@ async function downloadExchangeKeysInfo( globalFees: exchangeKeysJsonUnchecked.global_fees, accounts: exchangeKeysJsonUnchecked.accounts, wireFees: exchangeKeysJsonUnchecked.wire_fees, + currencySpecification: exchangeKeysJsonUnchecked.currency_specification, + }; +} + +type TosMetaResult = { type: "not-found" } | { type: "ok"; etag: string }; + +/** + * Download metadata about an exchange's terms of service. + */ +async function downloadTosMeta( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<TosMetaResult> { + logger.trace(`downloading exchange tos metadata for ${exchangeBaseUrl}`); + const reqUrl = new URL("terms", exchangeBaseUrl); + + // FIXME: We can/should make a HEAD request here. + // Not sure if qtart supports it at the moment. + const resp = await wex.http.fetch(reqUrl.href, { + cancellationToken: wex.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.NotFound: + case HttpStatusCode.NotImplemented: + return { type: "not-found" }; + case HttpStatusCode.Ok: + break; + default: + throwUnexpectedRequestError(resp, await readTalerErrorResponse(resp)); + } + + const etag = resp.headers.get("taler-terms-version") || "unknown"; + return { + type: "ok", + etag, }; } @@ -1008,132 +1061,6 @@ export interface ReadyExchangeSummary { scopeInfo: ScopeInfo; } -async function internalWaitReadyExchange( - wex: WalletExecutionContext, - canonUrl: string, - exchangeNotifFlag: AsyncFlag, - options: { - cancellationToken?: CancellationToken; - forceUpdate?: boolean; - expectedMasterPub?: string; - } = {}, -): Promise<ReadyExchangeSummary> { - const operationId = constructTaskIdentifier({ - tag: PendingTaskType.ExchangeUpdate, - exchangeBaseUrl: canonUrl, - }); - while (true) { - if (wex.cancellationToken.isCancelled) { - throw Error("cancelled"); - } - logger.info(`waiting for ready exchange ${canonUrl}`); - const { exchange, exchangeDetails, retryInfo, scopeInfo } = - await wex.db.runReadOnlyTx( - { - storeNames: [ - "exchanges", - "exchangeDetails", - "operationRetries", - "globalCurrencyAuditors", - "globalCurrencyExchanges", - ], - }, - async (tx) => { - const exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeRecordsInternal( - tx, - canonUrl, - ); - const retryInfo = await tx.operationRetries.get(operationId); - let scopeInfo: ScopeInfo | undefined = undefined; - if (exchange && exchangeDetails) { - scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); - } - return { exchange, exchangeDetails, retryInfo, scopeInfo }; - }, - ); - - if (!exchange) { - throw Error("exchange entry does not exist anymore"); - } - - let ready = false; - - switch (exchange.updateStatus) { - case ExchangeEntryDbUpdateStatus.Ready: - ready = true; - break; - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - // If the update is forced, - // we wait until we're in a full "ready" state, - // as we're not happy with the stale information. - if (!options.forceUpdate) { - ready = true; - } - break; - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError, - }, - ); - default: { - if (retryInfo) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError, - }, - ); - } - } - } - - if (!ready) { - logger.info("waiting for exchange update notification"); - await exchangeNotifFlag.wait(); - logger.info("done waiting for exchange update notification"); - exchangeNotifFlag.reset(); - continue; - } - - if (!exchangeDetails) { - throw Error("invariant failed"); - } - - if (!scopeInfo) { - throw Error("invariant failed"); - } - - const res: ReadyExchangeSummary = { - currency: exchangeDetails.currency, - exchangeBaseUrl: canonUrl, - masterPub: exchangeDetails.masterPublicKey, - tosStatus: getExchangeTosStatusFromRecord(exchange), - tosAcceptedEtag: exchange.tosAcceptedEtag, - wireInfo: exchangeDetails.wireInfo, - protocolVersionRange: exchangeDetails.protocolVersionRange, - tosCurrentEtag: exchange.tosCurrentEtag, - tosAcceptedTimestamp: timestampOptionalPreciseFromDb( - exchange.tosAcceptedTimestamp, - ), - scopeInfo, - }; - - if (options.expectedMasterPub) { - if (res.masterPub !== options.expectedMasterPub) { - throw Error( - "public key of the exchange does not match expected public key", - ); - } - } - return res; - } -} - /** * Ensure that a fresh exchange entry exists for the given * exchange base URL. @@ -1186,39 +1113,131 @@ async function waitReadyExchange( } = {}, ): Promise<ReadyExchangeSummary> { logger.trace(`waiting for exchange ${canonUrl} to become ready`); - // FIXME: We should use Symbol.dispose magic here for cleanup! - const exchangeNotifFlag = new AsyncFlag(); - // Raise exchangeNotifFlag whenever we get a notification - // about our exchange. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.ExchangeStateTransition && - notif.exchangeBaseUrl === canonUrl - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - exchangeNotifFlag.raise(); - } + const operationId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: canonUrl, }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { - cancelNotif(); - exchangeNotifFlag.raise(); + let res: ReadyExchangeSummary | undefined = undefined; + + await genericWaitForState(wex, { + filterNotification(notif): boolean { + return ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === canonUrl + ); + }, + async checkState(): Promise<boolean> { + const { exchange, exchangeDetails, retryInfo, scopeInfo } = + await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "operationRetries", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + }, + async (tx) => { + const exchange = await tx.exchanges.get(canonUrl); + const exchangeDetails = await getExchangeRecordsInternal( + tx, + canonUrl, + ); + const retryInfo = await tx.operationRetries.get(operationId); + let scopeInfo: ScopeInfo | undefined = undefined; + if (exchange && exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo( + tx, + exchangeDetails, + ); + } + return { exchange, exchangeDetails, retryInfo, scopeInfo }; + }, + ); + + if (!exchange) { + throw Error("exchange entry does not exist anymore"); + } + + let ready = false; + + switch (exchange.updateStatus) { + case ExchangeEntryDbUpdateStatus.Ready: + ready = true; + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + // If the update is forced, + // we wait until we're in a full "ready" state, + // as we're not happy with the stale information. + if (!options.forceUpdate) { + ready = true; + } + break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + default: { + if (retryInfo) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + } + } + } + + if (!ready) { + return false; + } + + if (!exchangeDetails) { + throw Error("invariant failed"); + } + + if (!scopeInfo) { + throw Error("invariant failed"); + } + + const mySummary: ReadyExchangeSummary = { + currency: exchangeDetails.currency, + exchangeBaseUrl: canonUrl, + masterPub: exchangeDetails.masterPublicKey, + tosStatus: getExchangeTosStatusFromRecord(exchange), + tosAcceptedEtag: exchange.tosAcceptedEtag, + wireInfo: exchangeDetails.wireInfo, + protocolVersionRange: exchangeDetails.protocolVersionRange, + tosCurrentEtag: exchange.tosCurrentEtag, + tosAcceptedTimestamp: timestampOptionalPreciseFromDb( + exchange.tosAcceptedTimestamp, + ), + scopeInfo, + }; + + if (options.expectedMasterPub) { + if (mySummary.masterPub !== options.expectedMasterPub) { + throw Error( + "public key of the exchange does not match expected public key", + ); + } + } + res = mySummary; + return true; + }, }); - try { - const res = await internalWaitReadyExchange( - wex, - canonUrl, - exchangeNotifFlag, - options, - ); - logger.info("done waiting for ready exchange"); - return res; - } finally { - unregisterOnCancelled(); - cancelNotif(); - } + checkLogicInvariant(!!res); + return res; } function checkPeerPaymentsDisabled( @@ -1286,6 +1305,10 @@ export async function updateExchangeFromUrlHandler( wex: WalletExecutionContext, exchangeBaseUrl: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + logger.trace(`updating exchange info for ${exchangeBaseUrl}`); const oldExchangeRec = await wex.db.runReadOnlyTx( @@ -1367,7 +1390,6 @@ export async function updateExchangeFromUrlHandler( AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp), ); } - } // When doing the auto-refresh check, we always update @@ -1423,15 +1445,7 @@ export async function updateExchangeFromUrlHandler( logger.trace("finished validating exchange /wire info"); - // We download the text/plain version here, - // because that one needs to exist, and we - // will get the current etag from the response. - const tosDownload = await downloadTosFromAcceptedFormat( - wex, - exchangeBaseUrl, - timeout, - ["text/plain"], - ); + const tosMeta = await downloadTosMeta(wex, exchangeBaseUrl); logger.trace("updating exchange info in database"); @@ -1460,6 +1474,7 @@ export async function updateExchangeFromUrlHandler( "recoupGroups", "coinAvailability", "denomLossEvents", + "currencyInfo", ], }, async (tx) => { @@ -1480,16 +1495,18 @@ export async function updateExchangeFromUrlHandler( detailsPointerChanged = true; } let detailsIncompatible = false; + let conflictHint: string | undefined = undefined; if (existingDetails) { if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) { detailsIncompatible = true; detailsPointerChanged = true; - } - if (existingDetails.currency !== keysInfo.currency) { + conflictHint = "master public key changed"; + } else if (existingDetails.currency !== keysInfo.currency) { detailsIncompatible = true; detailsPointerChanged = true; + conflictHint = "currency changed"; } - // FIXME: We need to do some consistency checks! + // FIXME: We need to do some more consistency checks! } if (detailsIncompatible) { logger.warn( @@ -1498,6 +1515,12 @@ export async function updateExchangeFromUrlHandler( // We don't support this gracefully right now. // See https://bugs.taler.net/n/8576 r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate; + r.unavailableReason = makeTalerErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT, + { + detail: conflictHint, + }, + ); r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1; r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter); r.nextRefreshCheckStamp = timestampPreciseToDb( @@ -1510,6 +1533,7 @@ export async function updateExchangeFromUrlHandler( newExchangeState: getExchangeState(r), }; } + delete r.unavailableReason; r.updateRetryCounter = 0; const newDetails: ExchangeDetailsRecord = { auditors: keysInfo.auditors, @@ -1524,7 +1548,14 @@ export async function updateExchangeFromUrlHandler( }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; - r.tosCurrentEtag = tosDownload.tosEtag; + switch (tosMeta.type) { + case "not-found": + r.tosCurrentEtag = undefined; + break; + case "ok": + r.tosCurrentEtag = tosMeta.etag; + break; + } if (existingDetails?.rowId) { newDetails.rowId = existingDetails.rowId; } @@ -1549,6 +1580,19 @@ export async function updateExchangeFromUrlHandler( r.updateStatus = ExchangeEntryDbUpdateStatus.Ready; r.cachebreakNextUpdate = false; await tx.exchanges.put(r); + + if (keysInfo.currencySpecification) { + await WalletDbHelpers.insertCurrencyInfoUnlessExists(tx, { + currencySpec: keysInfo.currencySpecification, + scopeInfo: { + type: ScopeType.Exchange, + currency: newDetails.currency, + url: exchangeBaseUrl, + }, + source: "exchange", + }); + } + const drRowId = await tx.exchangeDetails.put(newDetails); checkDbInvariant( typeof drRowId.key === "number", @@ -1671,12 +1715,13 @@ export async function updateExchangeFromUrlHandler( await wex.db.runReadWriteTx( { storeNames: [ + "coinAvailability", + "coinHistory", "coins", "denominations", - "coinAvailability", + "exchanges", "refreshGroups", "refreshSessions", - "exchanges", ], }, async (tx) => { @@ -2119,6 +2164,19 @@ export async function getExchangeTos( ): Promise<GetExchangeTosResult> { const exch = await fetchFreshExchange(wex, exchangeBaseUrl); + switch (exch.tosStatus) { + case ExchangeTosStatus.MissingTos: + return { + tosStatus: ExchangeTosStatus.MissingTos, + acceptedEtag: undefined, + contentLanguage: undefined, + contentType: "text/plain", + content: "NULL", + currentEtag: "NULL", + tosAvailableLanguages: [], + }; + } + const tosDownload = await downloadTosFromAcceptedFormat( wex, exchangeBaseUrl, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index ee154252f..efe6f6638 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -95,13 +95,18 @@ import { } from "@gnu-taler/taler-util"; import { getHttpResponseErrorDetails, + HttpResponse, readSuccessResponseJsonOrErrorCode, readSuccessResponseJsonOrThrow, readTalerErrorResponse, readUnexpectedResponseDetails, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; -import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js"; +import { + PreviousPayCoins, + selectPayCoins, + selectPayCoinsInTx, +} from "./coinSelection.js"; import { constructTaskIdentifier, PendingTaskType, @@ -278,13 +283,14 @@ export class PayMerchantTransactionContext implements TransactionContext { const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ - "purchases", - "refreshGroups", - "refreshSessions", - "denominations", "coinAvailability", + "coinHistory", "coins", + "denominations", "operationRetries", + "purchases", + "refreshGroups", + "refreshSessions", ], }, async (tx) => { @@ -472,33 +478,42 @@ export async function getTotalPaymentCost( return wex.db.runReadOnlyTx( { storeNames: ["coins", "denominations"] }, async (tx) => { - const costs: AmountJson[] = []; - for (let i = 0; i < pcs.length; i++) { - const denom = await tx.denominations.get([ - pcs[i].exchangeBaseUrl, - pcs[i].denomPubHash, - ]); - if (!denom) { - throw Error( - "can't calculate payment cost, denomination for coin not found", - ); - } - const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; - const refreshCost = await getTotalRefreshCost( - wex, - tx, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - costs.push(Amounts.parseOrThrow(pcs[i].contribution)); - costs.push(refreshCost); - } - const zero = Amounts.zeroOfCurrency(currency); - return Amounts.sum([zero, ...costs]).amount; + return getTotalPaymentCostInTx(wex, tx, currency, pcs); }, ); } +export async function getTotalPaymentCostInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, + currency: string, + pcs: SelectedProspectiveCoin[], +): Promise<AmountJson> { + const costs: AmountJson[] = []; + for (let i = 0; i < pcs.length; i++) { + const denom = await tx.denominations.get([ + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, + ]); + if (!denom) { + throw Error( + "can't calculate payment cost, denomination for coin not found", + ); + } + const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; + const refreshCost = await getTotalRefreshCost( + wex, + tx, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + costs.push(Amounts.parseOrThrow(pcs[i].contribution)); + costs.push(refreshCost); + } + const zero = Amounts.zeroOfCurrency(currency); + return Amounts.sum([zero, ...costs]).amount; +} + async function failProposalPermanently( wex: WalletExecutionContext, proposalId: string, @@ -533,13 +548,10 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration { ); } -/** - * Return the proposal download data for a purchase, throw if not available. - */ -export async function expectProposalDownload( +export async function expectProposalDownloadInTx( wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["contractTerms"]>, p: PurchaseRecord, - parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>, ): Promise<{ contractData: WalletContractData; contractTermsRaw: any; @@ -549,31 +561,35 @@ export async function expectProposalDownload( } const download = p.download; - async function getFromTransaction( - tx: Exclude<typeof parentTx, undefined>, - ): Promise<ReturnType<typeof expectProposalDownload>> { - const contractTerms = await tx.contractTerms.get( - download.contractTermsHash, - ); - if (!contractTerms) { - throw Error("contract terms not found"); - } - return { - contractData: extractContractData( - contractTerms.contractTermsRaw, - download.contractTermsHash, - download.contractTermsMerchantSig, - ), - contractTermsRaw: contractTerms.contractTermsRaw, - }; + const contractTerms = await tx.contractTerms.get(download.contractTermsHash); + if (!contractTerms) { + throw Error("contract terms not found"); } + return { + contractData: extractContractData( + contractTerms.contractTermsRaw, + download.contractTermsHash, + download.contractTermsMerchantSig, + ), + contractTermsRaw: contractTerms.contractTermsRaw, + }; +} - if (parentTx) { - return getFromTransaction(parentTx); - } +/** + * Return the proposal download data for a purchase, throw if not available. + */ +export async function expectProposalDownload( + wex: WalletExecutionContext, + p: PurchaseRecord, +): Promise<{ + contractData: WalletContractData; + contractTermsRaw: any; +}> { return await wex.db.runReadOnlyTx( { storeNames: ["contractTerms"] }, - getFromTransaction, + async (tx) => { + return expectProposalDownloadInTx(wex, tx, p); + }, ); } @@ -1046,7 +1062,7 @@ async function storeFirstPaySuccess( if (protoAr) { const ar = Duration.fromTalerProtocolDuration(protoAr); logger.info("auto_refund present"); - purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund; + purchase.purchaseStatus = PurchaseStatus.FinalizingQueryingAutoRefund; purchase.autoRefundDeadline = timestampProtocolToDb( AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration(AbsoluteTime.now(), ar), @@ -1117,6 +1133,8 @@ async function handleInsufficientFunds( ): Promise<void> { logger.trace("handling insufficient funds, trying to re-select coins"); + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const proposal = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { @@ -1148,8 +1166,6 @@ async function handleInsufficientFunds( throw new TalerProtocolViolationError(); } - const { contractData } = await expectProposalDownload(wex, proposal); - const prevPayCoins: PreviousPayCoins = []; const payInfo = proposal.payInfo; @@ -1162,53 +1178,19 @@ async function handleInsufficientFunds( return; } - await wex.db.runReadOnlyTx( - { storeNames: ["coins", "denominations"] }, - async (tx) => { - for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { - const coinPub = payCoinSelection.coinPubs[i]; - const contrib = payCoinSelection.coinContributions[i]; - prevPayCoins.push({ - coinPub, - contribution: Amounts.parseOrThrow(contrib), - }); - } - }, - ); - - const res = await selectPayCoins(wex, { - restrictExchanges: { - auditors: [], - exchanges: contractData.allowedExchanges, - }, - restrictWireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - prevPayCoins, - requiredMinimumAge: contractData.minimumAge, - }); - - switch (res.type) { - case "failure": - logger.trace("insufficient funds for coin re-selection"); - return; - case "prospective": - return; - case "success": - break; - default: - assertUnreachable(res); - } - - logger.trace("re-selected coins"); + // FIXME: Above code should go into the transaction. await wex.db.runReadWriteTx( { storeNames: [ - "purchases", - "coins", "coinAvailability", + "coinHistory", + "coins", + "contractTerms", "denominations", + "exchangeDetails", + "exchanges", + "purchases", "refreshGroups", "refreshSessions", ], @@ -1222,6 +1204,46 @@ async function handleInsufficientFunds( if (!payInfo) { return; } + + const { contractData } = await expectProposalDownloadInTx( + wex, + tx, + proposal, + ); + + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + const contrib = payCoinSelection.coinContributions[i]; + prevPayCoins.push({ + coinPub, + contribution: Amounts.parseOrThrow(contrib), + }); + } + + const res = await selectPayCoinsInTx(wex, tx, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins, + requiredMinimumAge: contractData.minimumAge, + }); + + switch (res.type) { + case "failure": + logger.trace("insufficient funds for coin re-selection"); + return; + case "prospective": + return; + case "success": + break; + default: + assertUnreachable(res); + } + // Convert to DB format payInfo.payCoinSelection = { coinContributions: res.coinSel.coins.map((x) => x.contribution), @@ -1230,11 +1252,7 @@ async function handleInsufficientFunds( payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); await tx.purchases.put(p); await spendCoins(wex, tx, { - // allocationId: `txn:proposal:${p.proposalId}`, - allocationId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: proposalId, - }), + transactionId: ctx.transactionId, coinPubs: payInfo.payCoinSelection.coinPubs, contributions: payInfo.payCoinSelection.coinContributions.map((x) => Amounts.parseOrThrow(x), @@ -1444,6 +1462,7 @@ async function checkPaymentByProposalId( const paid = purchase.purchaseStatus === PurchaseStatus.Done || purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund || + purchase.purchaseStatus === PurchaseStatus.FinalizingQueryingAutoRefund || purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund; const download = await expectProposalDownload(wex, purchase); return { @@ -1936,44 +1955,6 @@ export async function confirmPay( const currency = Amounts.currencyOf(contractData.amount); - const selectCoinsResult = await selectPayCoins(wex, { - restrictExchanges: { - auditors: [], - exchanges: contractData.allowedExchanges, - }, - restrictWireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - prevPayCoins: [], - requiredMinimumAge: contractData.minimumAge, - forcedSelection: forcedCoinSel, - }); - - let coins: SelectedProspectiveCoin[] | undefined = undefined; - - switch (selectCoinsResult.type) { - case "failure": { - // Should not happen, since checkPay should be called first - // FIXME: Actually, this should be handled gracefully, - // and the status should be stored in the DB. - logger.warn("not confirming payment, insufficient coins"); - throw Error("insufficient balance"); - } - case "prospective": { - coins = selectCoinsResult.result.prospectiveCoins; - break; - } - case "success": - coins = selectCoinsResult.coinSel.coins; - break; - default: - assertUnreachable(selectCoinsResult); - } - - logger.trace("coin selection result", selectCoinsResult); - - const payCostInfo = await getTotalPaymentCost(wex, currency, coins); - let sessionId: string | undefined; if (sessionIdOverride) { sessionId = sessionIdOverride; @@ -1988,12 +1969,15 @@ export async function confirmPay( const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ - "purchases", + "coinAvailability", + "coinHistory", "coins", + "denominations", + "exchangeDetails", + "exchanges", + "purchases", "refreshGroups", "refreshSessions", - "denominations", - "coinAvailability", ], }, async (tx) => { @@ -2001,6 +1985,50 @@ export async function confirmPay( if (!p) { return; } + + const selectCoinsResult = await selectPayCoinsInTx(wex, tx, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + forcedSelection: forcedCoinSel, + }); + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (selectCoinsResult.type) { + case "failure": { + // Should not happen, since checkPay should be called first + // FIXME: Actually, this should be handled gracefully, + // and the status should be stored in the DB. + logger.warn("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + case "prospective": { + coins = selectCoinsResult.result.prospectiveCoins; + break; + } + case "success": + coins = selectCoinsResult.coinSel.coins; + break; + default: + assertUnreachable(selectCoinsResult); + } + + logger.trace("coin selection result", selectCoinsResult); + + const payCostInfo = await getTotalPaymentCostInTx( + wex, + tx, + currency, + coins, + ); + const oldTxState = computePayMerchantTransactionState(p); switch (p.purchaseStatus) { case PurchaseStatus.DialogShared: @@ -2024,11 +2052,7 @@ export async function confirmPay( if (p.payInfo.payCoinSelection) { const sel = p.payInfo.payCoinSelection; await spendCoins(wex, tx, { - //`txn:proposal:${p.proposalId}` - allocationId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: proposalId, - }), + transactionId: transactionId as TransactionIdStr, coinPubs: sel.coinPubs, contributions: sel.coinContributions.map((x) => Amounts.parseOrThrow(x), @@ -2036,7 +2060,6 @@ export async function confirmPay( refreshReason: RefreshReason.PayMerchant, }); } - break; case PurchaseStatus.Done: case PurchaseStatus.PendingPaying: @@ -2086,6 +2109,10 @@ export async function processPurchase( }; } + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + switch (purchase.purchaseStatus) { case PurchaseStatus.PendingDownloadingProposal: return processDownloadProposal(wex, proposalId); @@ -2093,8 +2120,8 @@ export async function processPurchase( case PurchaseStatus.PendingPayingReplay: return processPurchasePay(wex, proposalId); case PurchaseStatus.PendingQueryingRefund: - case PurchaseStatus.FinalizingQueryingAutoRefund: return processPurchaseQueryRefund(wex, purchase); + case PurchaseStatus.FinalizingQueryingAutoRefund: case PurchaseStatus.PendingQueryingAutoRefund: return processPurchaseAutoRefund(wex, purchase); case PurchaseStatus.AbortingWithRefund: @@ -2251,12 +2278,13 @@ async function processPurchasePay( const transitionDone = await wex.db.runReadWriteTx( { storeNames: [ - "purchases", + "coinAvailability", + "coinHistory", "coins", + "denominations", + "purchases", "refreshGroups", "refreshSessions", - "denominations", - "coinAvailability", ], }, async (tx) => { @@ -2284,11 +2312,7 @@ async function processPurchasePay( await tx.purchases.put(p); await spendCoins(wex, tx, { - //`txn:proposal:${p.proposalId}` - allocationId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: proposalId, - }), + transactionId: ctx.transactionId, coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), contributions: selectCoinsResult.coinSel.coins.map((x) => Amounts.parseOrThrow(x.contribution), @@ -2851,14 +2875,25 @@ async function checkIfOrderIsAlreadyPaid( ); requestUrl.searchParams.set("h_contract", contract.contractTermsHash); + let resp: HttpResponse; + if (doLongPolling) { - requestUrl.searchParams.set("timeout_ms", "30000"); + resp = await wex.ws.runLongpollQueueing( + wex, + requestUrl.hostname, + async (timeoutMs) => { + requestUrl.searchParams.set("timeout_ms", `${timeoutMs}`); + return await wex.http.fetch(requestUrl.href, { + cancellationToken: wex.cancellationToken, + }); + }, + ); + } else { + resp = await wex.http.fetch(requestUrl.href, { + cancellationToken: wex.cancellationToken, + }); } - const resp = await wex.http.fetch(requestUrl.href, { - cancellationToken: wex.cancellationToken, - }); - if ( resp.status === HttpStatusCode.Ok || resp.status === HttpStatusCode.Accepted || @@ -2998,12 +3033,18 @@ async function processPurchaseAutoRefund( download.contractData.contractTermsHash, ); - requestUrl.searchParams.set("timeout_ms", "10000"); requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund)); - const resp = await wex.http.fetch(requestUrl.href, { - cancellationToken: wex.cancellationToken, - }); + const resp = await wex.ws.runLongpollQueueing( + wex, + requestUrl.hostname, + async (timeoutMs) => { + requestUrl.searchParams.set("timeout_ms", `${timeoutMs}`); + return await wex.http.fetch(requestUrl.href, { + cancellationToken: wex.cancellationToken, + }); + }, + ); // FIXME: Check other status codes! @@ -3389,16 +3430,17 @@ async function storeRefunds( const result = await wex.db.runReadWriteTx( { storeNames: [ + "coinAvailability", + "coinHistory", + "coins", "coins", "denominations", - "purchases", - "refundItems", - "refundGroups", "denominations", - "coins", - "coinAvailability", + "purchases", "refreshGroups", "refreshSessions", + "refundGroups", + "refundItems", ], }, async (tx) => { diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts index a1729ced7..636dd4156 100644 --- a/packages/taler-wallet-core/src/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -31,7 +31,11 @@ import { codecOptional, } from "@gnu-taler/taler-util"; import { SpendCoinDetails } from "./crypto/cryptoImplementation.js"; -import { DbPeerPushPaymentCoinSelection, ReserveRecord } from "./db.js"; +import { + DbPeerPushPaymentCoinSelection, + ReserveRecord, + WalletDbReadOnlyTransaction, +} from "./db.js"; import { getTotalRefreshCost } from "./refresh.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; @@ -74,6 +78,38 @@ export async function queryCoinInfosForSelection( return infos; } +export async function getTotalPeerPaymentCostInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, + pcs: SelectedProspectiveCoin[], +): Promise<AmountJson> { + const costs: AmountJson[] = []; + for (let i = 0; i < pcs.length; i++) { + const denomInfo = await getDenomInfo( + wex, + tx, + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, + ); + if (!denomInfo) { + throw Error( + "can't calculate payment cost, denomination for coin not found", + ); + } + const amountLeft = Amounts.sub(denomInfo.value, pcs[i].contribution).amount; + const refreshCost = await getTotalRefreshCost( + wex, + tx, + denomInfo, + amountLeft, + ); + costs.push(Amounts.parseOrThrow(pcs[i].contribution)); + costs.push(refreshCost); + } + const zero = Amounts.zeroOfAmount(pcs[0].contribution); + return Amounts.sum([zero, ...costs]).amount; +} + export async function getTotalPeerPaymentCost( wex: WalletExecutionContext, pcs: SelectedProspectiveCoin[], @@ -81,34 +117,7 @@ export async function getTotalPeerPaymentCost( return wex.db.runReadOnlyTx( { storeNames: ["coins", "denominations"] }, async (tx) => { - const costs: AmountJson[] = []; - for (let i = 0; i < pcs.length; i++) { - const denomInfo = await getDenomInfo( - wex, - tx, - pcs[i].exchangeBaseUrl, - pcs[i].denomPubHash, - ); - if (!denomInfo) { - throw Error( - "can't calculate payment cost, denomination for coin not found", - ); - } - const amountLeft = Amounts.sub( - denomInfo.value, - pcs[i].contribution, - ).amount; - const refreshCost = await getTotalRefreshCost( - wex, - tx, - denomInfo, - amountLeft, - ); - costs.push(Amounts.parseOrThrow(pcs[i].contribution)); - costs.push(refreshCost); - } - const zero = Amounts.zeroOfAmount(pcs[0].contribution); - return Amounts.sum([zero, ...costs]).amount; + return getTotalPeerPaymentCostInTx(wex, tx, pcs); }, ); } @@ -143,7 +152,10 @@ export async function getMergeReserveInfo( checkDbInvariant(!!ex, `no exchange record for ${req.exchangeBaseUrl}`); if (ex.currentMergeReserveRowId != null) { const reserve = await tx.reserves.get(ex.currentMergeReserveRowId); - checkDbInvariant(!!reserve, `reserver ${ex.currentMergeReserveRowId} missing in db`); + checkDbInvariant( + !!reserve, + `reserver ${ex.currentMergeReserveRowId} missing in db`, + ); return reserve; } const reserve: ReserveRecord = { @@ -151,7 +163,10 @@ export async function getMergeReserveInfo( reservePub: newReservePair.pub, }; const insertResp = await tx.reserves.put(reserve); - checkDbInvariant(typeof insertResp.key === "number", `reserve key is not a number`); + checkDbInvariant( + typeof insertResp.key === "number", + `reserve key is not a number`, + ); reserve.rowId = insertResp.key; ex.currentMergeReserveRowId = reserve.rowId; await tx.exchanges.put(ex); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts index b7fb13da3..2ddebab29 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -59,6 +59,7 @@ import { TombstoneTag, TransactionContext, constructTaskIdentifier, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { KycPendingInfo, @@ -373,10 +374,16 @@ async function queryPurseForPeerPullCredit( ); purseDepositUrl.searchParams.set("timeout_ms", "30000"); logger.info(`querying purse status via ${purseDepositUrl.href}`); - const resp = await wex.http.fetch(purseDepositUrl.href, { - timeout: { d_ms: 60000 }, - cancellationToken: wex.cancellationToken, - }); + const resp = await wex.ws.runLongpollQueueing( + wex, + purseDepositUrl.hostname, + async () => { + return await wex.http.fetch(purseDepositUrl.href, { + timeout: { d_ms: 60000 }, + cancellationToken: wex.cancellationToken, + }); + }, + ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullIni.pursePub, @@ -487,12 +494,18 @@ async function longpollKycStatus( `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, exchangeUrl, ); - url.searchParams.set("timeout_ms", "10000"); - logger.info(`kyc url ${url.href}`); - const kycStatusRes = await wex.http.fetch(url.href, { - method: "GET", - cancellationToken: wex.cancellationToken, - }); + const kycStatusRes = await wex.ws.runLongpollQueueing( + wex, + url.hostname, + async (timeoutMs) => { + url.searchParams.set("timeout_ms", `${timeoutMs}`); + logger.info(`kyc url ${url.href}`); + return await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + }, + ); if ( kycStatusRes.status === HttpStatusCode.Ok || // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge @@ -766,6 +779,10 @@ export async function processPeerPullCredit( wex: WalletExecutionContext, pursePub: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + const pullIni = await wex.db.runReadOnlyTx( { storeNames: ["peerPullCredit"] }, async (tx) => { @@ -933,6 +950,11 @@ export async function checkPeerPullPaymentInitiation( Amounts.parseOrThrow(req.amount), undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to check pull payment from ${exchangeUrl}, can't select denominations for instructed amount (${req.amount}`, + ); + } logger.trace(`got withdrawal info`); @@ -1021,7 +1043,8 @@ export async function initiatePeerPullPayment( const exchangeBaseUrl = maybeExchangeBaseUrl; - await fetchFreshExchange(wex, exchangeBaseUrl); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + requireExchangeTosAcceptedOrThrow(exchange); const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: exchangeBaseUrl, @@ -1052,6 +1075,11 @@ export async function initiatePeerPullPayment( Amounts.parseOrThrow(req.partialContractTerms.amount), undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to initiate pull payment from ${exchangeBaseUrl}, can't select denominations for instructed amount (${req.partialContractTerms.amount}`, + ); + } const mergeTimestamp = TalerPreciseTimestamp.now(); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts index e9be15026..4ad324359 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -237,11 +237,12 @@ export class PeerPullDebitTransactionContext implements TransactionContext { { extraStores: [ "coinAvailability", + "coinAvailability", + "coinHistory", + "coins", "denominations", "refreshGroups", "refreshSessions", - "coins", - "coinAvailability", ], }, async (pi, tx) => { @@ -475,13 +476,14 @@ async function processPeerPullDebitPendingDeposit( const transitionDone = await wex.db.runReadWriteTx( { storeNames: [ - "exchanges", + "coinAvailability", + "coinHistory", "coins", "denominations", + "exchanges", + "peerPullDebit", "refreshGroups", "refreshSessions", - "peerPullDebit", - "coinAvailability", ], }, async (tx) => { @@ -496,11 +498,7 @@ async function processPeerPullDebitPendingDeposit( return false; } await spendCoins(wex, tx, { - // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, - allocationId: constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }), + transactionId: ctx.transactionId, coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), contributions: coinSelRes.result.coins.map((x) => Amounts.parseOrThrow(x.contribution), @@ -654,6 +652,10 @@ export async function processPeerPullDebit( wex: WalletExecutionContext, peerPullDebitId: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + const peerPullInc = await wex.db.runReadOnlyTx( { storeNames: ["peerPullDebit"] }, async (tx) => { @@ -697,6 +699,9 @@ export async function confirmPeerPullDebit( ); } + const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId); + const transactionId = ctx.transactionId; + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); const coinSelRes = await selectPeerCoins(wex, { @@ -733,13 +738,14 @@ export async function confirmPeerPullDebit( await wex.db.runReadWriteTx( { storeNames: [ - "exchanges", + "coinAvailability", + "coinHistory", "coins", "denominations", + "exchanges", + "peerPullDebit", "refreshGroups", "refreshSessions", - "peerPullDebit", - "coinAvailability", ], }, async (tx) => { @@ -752,11 +758,7 @@ export async function confirmPeerPullDebit( } if (coinSelRes.type == "success") { await spendCoins(wex, tx, { - // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, - allocationId: constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }), + transactionId, coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), contributions: coinSelRes.result.coins.map((x) => Amounts.parseOrThrow(x.contribution), @@ -774,10 +776,6 @@ export async function confirmPeerPullDebit( }, ); - const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId); - - const transactionId = ctx.transactionId; - wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts index 6d9f329e5..bd4adc0b6 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -61,6 +61,7 @@ import { TombstoneTag, TransactionContext, constructTaskIdentifier, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { KycPendingInfo, @@ -407,8 +408,6 @@ export async function preparePeerPushCredit( const exchangeBaseUrl = uri.exchangeBaseUrl; - await fetchFreshExchange(wex, exchangeBaseUrl); - const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); @@ -459,6 +458,12 @@ export async function preparePeerPushCredit( undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to prepare push credit from ${exchangeBaseUrl}, can't select denominations for instructed amount (${purseStatus.balance}`, + ); + } + const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "peerPushCredit"] }, async (tx) => { @@ -532,12 +537,19 @@ async function longpollKycStatus( `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, exchangeUrl, ); - url.searchParams.set("timeout_ms", "30000"); logger.info(`kyc url ${url.href}`); - const kycStatusRes = await wex.http.fetch(url.href, { - method: "GET", - cancellationToken: wex.cancellationToken, - }); + const kycStatusRes = await wex.ws.runLongpollQueueing( + wex, + url.hostname, + async (timeoutMs) => { + url.searchParams.set("timeout_ms", `${timeoutMs}`); + return await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + }, + ); + if ( kycStatusRes.status === HttpStatusCode.Ok || //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge @@ -845,6 +857,10 @@ export async function processPeerPushCredit( wex: WalletExecutionContext, peerPushCreditId: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + let peerInc: PeerPushPaymentIncomingRecord | undefined; let contractTerms: PeerContractTerms | undefined; await wex.db.runReadWriteTx( @@ -906,7 +922,7 @@ export async function confirmPeerPushCredit( wex: WalletExecutionContext, req: ConfirmPeerPushCreditRequest, ): Promise<AcceptPeerPushPaymentResponse> { - let peerInc: PeerPushPaymentIncomingRecord | undefined; + // PeerPushPaymentIncomingRecord | undefined; let peerPushCreditId: string; const parsedTx = parseTransactionIdentifier(req.transactionId); if (!parsedTx) { @@ -919,17 +935,18 @@ export async function confirmPeerPushCredit( logger.trace(`confirming peer-push-credit ${peerPushCreditId}`); - await wex.db.runReadWriteTx( + const peerInc = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "peerPushCredit"] }, async (tx) => { - peerInc = await tx.peerPushCredit.get(peerPushCreditId); - if (!peerInc) { + const rec = await tx.peerPushCredit.get(peerPushCreditId); + if (!rec) { return; } - if (peerInc.status === PeerPushCreditStatus.DialogProposed) { - peerInc.status = PeerPushCreditStatus.PendingMerge; + if (rec.status === PeerPushCreditStatus.DialogProposed) { + rec.status = PeerPushCreditStatus.PendingMerge; } - await tx.peerPushCredit.put(peerInc); + await tx.peerPushCredit.put(rec); + return rec; }, ); @@ -939,6 +956,9 @@ export async function confirmPeerPushCredit( ); } + const exchange = await fetchFreshExchange(wex, peerInc.exchangeBaseUrl); + requireExchangeTosAcceptedOrThrow(exchange); + const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId); wex.taskScheduler.startShepherdTask(ctx.taskId); diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts index f8e6adb3c..f6bc605a0 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -51,7 +51,11 @@ import { readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; -import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js"; +import { + PreviousPayCoins, + selectPeerCoins, + selectPeerCoinsInTx, +} from "./coinSelection.js"; import { PendingTaskType, TaskIdStr, @@ -73,6 +77,7 @@ import { import { codecForExchangePurseStatus, getTotalPeerPaymentCost, + getTotalPeerPaymentCostInTx, queryCoinInfosForSelection, } from "./pay-peer-common.js"; import { createRefreshGroup, waitRefreshFinal } from "./refresh.js"; @@ -511,14 +516,15 @@ async function processPeerPushDebitCreateReserve( const transitionDone = await wex.db.runReadWriteTx( { storeNames: [ - "exchanges", - "contractTerms", - "coins", "coinAvailability", + "coinHistory", + "coins", + "contractTerms", "denominations", + "exchanges", + "peerPushDebit", "refreshGroups", "refreshSessions", - "peerPushDebit", ], }, async (tx) => { @@ -538,10 +544,7 @@ async function processPeerPushDebitCreateReserve( // we might want to mark the coins as used and spend them // after we've been able to create the purse. await spendCoins(wex, tx, { - allocationId: constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub, - }), + transactionId: ctx.transactionId, coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), contributions: coinSelRes.result.coins.map((x) => Amounts.parseOrThrow(x.contribution), @@ -730,12 +733,13 @@ async function processPeerPushDebitAbortingDeletePurse( const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ + "coinAvailability", + "coinHistory", + "coins", + "denominations", "peerPushDebit", "refreshGroups", "refreshSessions", - "denominations", - "coinAvailability", - "coins", ], }, async (tx) => { @@ -940,12 +944,18 @@ async function processPeerPushDebitReady( `purses/${pursePub}/merge`, peerPushInitiation.exchangeBaseUrl, ); - mergeUrl.searchParams.set("timeout_ms", "30000"); - logger.info(`long-polling on purse status at ${mergeUrl.href}`); - const resp = await wex.http.fetch(mergeUrl.href, { - // timeout: getReserveRequestTimeout(withdrawalGroup), - cancellationToken: wex.cancellationToken, - }); + const resp = await wex.ws.runLongpollQueueing( + wex, + mergeUrl.hostname, + async (timeoutMs) => { + mergeUrl.searchParams.set("timeout_ms", `${timeoutMs}`); + logger.info(`long-polling on purse status at ${mergeUrl.href}`); + return await wex.http.fetch(mergeUrl.href, { + // timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + }, + ); if (resp.status === HttpStatusCode.Ok) { const purseStatus = await readSuccessResponseJsonOrThrow( resp, @@ -971,12 +981,13 @@ async function processPeerPushDebitReady( const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ + "coinAvailability", + "coinHistory", + "coins", + "denominations", "peerPushDebit", "refreshGroups", "refreshSessions", - "denominations", - "coinAvailability", - "coins", ], }, async (tx) => { @@ -1031,6 +1042,10 @@ export async function processPeerPushDebit( wex: WalletExecutionContext, pursePub: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + const peerPushInitiation = await wex.db.runReadOnlyTx( { storeNames: ["peerPushDebit"] }, async (tx) => { @@ -1089,39 +1104,6 @@ export async function initiatePeerPushDebit( const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({}); - const coinSelRes = await selectPeerCoins(wex, { - instructedAmount, - }); - - let coins: SelectedProspectiveCoin[] | undefined = undefined; - - switch (coinSelRes.type) { - case "failure": - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); - case "prospective": - coins = coinSelRes.result.prospectiveCoins; - break; - case "success": - coins = coinSelRes.result.coins; - break; - default: - assertUnreachable(coinSelRes); - } - - const sel = coinSelRes.result; - - logger.info(`selected p2p coins (push):`); - logger.trace(`${j2s(coinSelRes)}`); - - const totalAmount = await getTotalPeerPaymentCost(wex, coins); - - logger.info(`computed total peer payment cost`); - const pursePub = pursePair.pub; const ctx = new PeerPushDebitTransactionContext(wex, pursePub); @@ -1130,20 +1112,49 @@ export async function initiatePeerPushDebit( const contractEncNonce = encodeCrock(getRandomBytes(24)); - const transitionInfo = await wex.db.runReadWriteTx( + const res = await wex.db.runReadWriteTx( { storeNames: [ - "exchanges", - "contractTerms", - "coins", "coinAvailability", + "coinHistory", + "coins", + "contractTerms", "denominations", + "exchangeDetails", + "exchanges", + "peerPushDebit", "refreshGroups", "refreshSessions", - "peerPushDebit", ], }, async (tx) => { + const coinSelRes = await selectPeerCoinsInTx(wex, tx, { + instructedAmount, + }); + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = coinSelRes.result.prospectiveCoins; + break; + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const sel = coinSelRes.result; + + const totalAmount = await getTotalPeerPaymentCostInTx(wex, tx, coins); const ppi: PeerPushDebitRecord = { amount: Amounts.stringify(instructedAmount), contractPriv: contractKeyPair.priv, @@ -1170,10 +1181,7 @@ export async function initiatePeerPushDebit( // we might want to mark the coins as used and spend them // after we've been able to create the purse. await spendCoins(wex, tx, { - allocationId: constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub: pursePair.pub, - }), + transactionId: ctx.transactionId, coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), contributions: coinSelRes.result.coins.map((x) => Amounts.parseOrThrow(x.contribution), @@ -1191,12 +1199,15 @@ export async function initiatePeerPushDebit( const newTxState = computePeerPushDebitTransactionState(ppi); return { - oldTxState: { major: TransactionMajorState.None }, - newTxState, + transitionInfo: { + oldTxState: { major: TransactionMajorState.None }, + newTxState, + }, + exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, }; }, ); - notifyTransition(wex, transactionId, transitionInfo); + notifyTransition(wex, transactionId, res.transitionInfo); wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, @@ -1208,7 +1219,7 @@ export async function initiatePeerPushDebit( contractPriv: contractKeyPair.priv, mergePriv: mergePair.priv, pursePub: pursePair.pub, - exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, + exchangeBaseUrl: res.exchangeBaseUrl, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushDebit, pursePub: pursePair.pub, diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts index dc15bbdd1..a1710b1cd 100644 --- a/packages/taler-wallet-core/src/query.ts +++ b/packages/taler-wallet-core/src/query.ts @@ -41,6 +41,7 @@ import { Codec, Logger, openPromise, + safeStringifyException, } from "@gnu-taler/taler-util"; const logger = new Logger("query.ts"); @@ -83,9 +84,17 @@ function requestToPromise(req: IDBRequest): Promise<any> { resolve(req.result); }; req.onerror = () => { - console.error("error in DB request", req.error); + if ( + req.error != null && + "name" in req.error && + req.error.name === "AbortError" + ) { + console.warn("DB request failed, transaction aborted"); + } else { + console.error("error in DB request", req.error); + console.error("Request failed:", stack); + } reject(req.error); - console.error("Request failed:", stack); }; }); } @@ -565,34 +574,55 @@ function runTx<Arg, Res>( arg: Arg, f: (t: Arg, t2: IDBTransaction) => Promise<Res>, triggerContext: InternalTriggerContext, + cancellationToken: CancellationToken, ): Promise<Res> { + cancellationToken.throwIfCancelled(); + // Create stack trace in case we need to to print later where + // the transaction was started. const stack = Error("Failed transaction was started here."); + + const unregisterOnCancelled = cancellationToken.onCancelled(() => { + tx.abort(); + }); + return new Promise((resolve, reject) => { let funResult: any = undefined; let gotFunResult = false; let transactionException: any = undefined; + let aborted = false; tx.oncomplete = () => { // This is a fatal error: The transaction completed *before* // the transaction function returned. Likely, the transaction // function waited on a promise that is *not* resolved in the // microtask queue, thus triggering the auto-commit behavior. // Unfortunately, the auto-commit behavior of IDB can't be switched - // of. There are some proposals to add this functionality in the future. + // off. There are some proposals to add this functionality in the future. if (!gotFunResult) { const msg = "BUG: transaction closed before transaction function returned"; logger.error(msg); logger.error(`${stack.stack}`); reject(Error(msg)); + } else { + resolve(funResult); } triggerContext.handleAfterCommit(); - resolve(funResult); + unregisterOnCancelled(); }; tx.onerror = () => { + if (cancellationToken.isCancelled) { + return; + } logger.error("error in transaction"); logger.error(`${stack.stack}`); }; tx.onabort = () => { + if (cancellationToken.isCancelled) { + reject( + new CancellationToken.CancellationError(cancellationToken.reason), + ); + return; + } let msg: string; if (tx.error) { msg = `Transaction aborted (transaction error): ${tx.error}`; @@ -601,6 +631,8 @@ function runTx<Arg, Res>( } else { msg = "Transaction aborted (no DB error)"; } + aborted = true; + unregisterOnCancelled(); logger.error(msg); logger.error(`${stack.stack}`); reject(new TransactionAbortedError(msg)); @@ -614,6 +646,9 @@ function runTx<Arg, Res>( .catch((e) => { if (e == TransactionAbort) { logger.trace("aborting transaction"); + tx.abort(); + } else if ("name" in e && e.name === "AbortError") { + console.warn("got AbortError, transaction was aborted"); } else { transactionException = e; console.error("Transaction failed:", e); @@ -622,7 +657,7 @@ function runTx<Arg, Res>( } }) .catch((e) => { - console.error("fatal: aborting transaction failed", e); + console.error("aborting failed:", safeStringifyException(e)); }); }); } @@ -797,9 +832,26 @@ function makeWriteContext( return ctx; } +/** + * Handle for typed access to a database. + */ export interface DbAccess<StoreMap> { + /** + * The underlying IndexedDB database handle. + * + * Use with caution, as using the handle directly will not + * properly run DB triggers. + */ idbHandle(): IDBDatabase; + /** + * Run an async function in a "readwrite" transaction on the database, using + * all object store. + * + * The transaction function must run within the microtask queue. + * Waiting for macrotasks results in an autocommit and + * a subsequent exception thrown by this function. + */ runAllStoresReadWriteTx<T>( options: { label?: string; @@ -809,6 +861,14 @@ export interface DbAccess<StoreMap> { ) => Promise<T>, ): Promise<T>; + /** + * Run an async function in a "readonly" transaction on the database, using + * all object store. + * + * The transaction function must run within the microtask queue. + * Waiting for macrotasks results in an autocommit and + * a subsequent exception thrown by this function. + */ runAllStoresReadOnlyTx<T>( options: { label?: string; @@ -818,6 +878,14 @@ export interface DbAccess<StoreMap> { ) => Promise<T>, ): Promise<T>; + /** + * Run an async function in a "readwrite" transaction on the database, using + * the selected object store. + * + * The transaction function must run within the microtask queue. + * Waiting for macrotasks results in an autocommit and + * a subsequent exception thrown by this function. + */ runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>( opts: { storeNames: StoreNameArray; @@ -826,6 +894,14 @@ export interface DbAccess<StoreMap> { txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>, ): Promise<T>; + /** + * Run an async function in a "readonly" transaction on the database, using + * the selected object store. + * + * The transaction function must run within the microtask queue. + * Waiting for macrotasks results in an autocommit and + * a subsequent exception thrown by this function. + */ runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>( opts: { storeNames: StoreNameArray; @@ -919,7 +995,7 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { ); const tx = this.db.transaction(strStoreNames, mode); const writeContext = makeWriteContext(tx, accessibleStores, triggerContext); - return runTx(tx, writeContext, txf, triggerContext); + return runTx(tx, writeContext, txf, triggerContext, this.cancellationToken); } async runAllStoresReadOnlyTx<T>( @@ -946,7 +1022,13 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { ); const tx = this.db.transaction(strStoreNames, mode); const writeContext = makeReadContext(tx, accessibleStores, triggerContext); - const res = await runTx(tx, writeContext, txf, triggerContext); + const res = await runTx( + tx, + writeContext, + txf, + triggerContext, + this.cancellationToken, + ); return res; } @@ -972,7 +1054,13 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { ); const tx = this.db.transaction(strStoreNames, mode); const writeContext = makeWriteContext(tx, accessibleStores, triggerContext); - const res = await runTx(tx, writeContext, txf, triggerContext); + const res = await runTx( + tx, + writeContext, + txf, + triggerContext, + this.cancellationToken, + ); return res; } @@ -998,7 +1086,13 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { ); const tx = this.db.transaction(strStoreNames, mode); const readContext = makeReadContext(tx, accessibleStores, triggerContext); - const res = runTx(tx, readContext, txf, triggerContext); + const res = runTx( + tx, + readContext, + txf, + triggerContext, + this.cancellationToken, + ); return res; } } diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts index be5731b0b..43e9af625 100644 --- a/packages/taler-wallet-core/src/recoup.ts +++ b/packages/taler-wallet-core/src/recoup.ts @@ -199,24 +199,20 @@ async function recoupRefreshCoin( revokedCoin.exchangeBaseUrl, revokedCoin.denomPubHash, ); - checkDbInvariant(!!oldCoinDenom, `no denom for coin, hash ${oldCoin.denomPubHash}`); - checkDbInvariant(!!revokedCoinDenom, `no revoked denom for coin, hash ${revokedCoin.denomPubHash}`); + checkDbInvariant( + !!oldCoinDenom, + `no denom for coin, hash ${oldCoin.denomPubHash}`, + ); + checkDbInvariant( + !!revokedCoinDenom, + `no revoked denom for coin, hash ${revokedCoin.denomPubHash}`, + ); revokedCoin.status = CoinStatus.Dormant; - if (!revokedCoin.spendAllocation) { - // We don't know what happened to this coin - logger.error( - `can't refresh-recoup coin ${revokedCoin.coinPub}, no spendAllocation known`, - ); - } else { - let residualAmount = Amounts.sub( - revokedCoinDenom.value, - revokedCoin.spendAllocation.amount, - ).amount; - recoupGroup.scheduleRefreshCoins.push({ - coinPub: oldCoin.coinPub, - amount: Amounts.stringify(residualAmount), - }); - } + // FIXME: Schedule recoup for the sum of refreshes, based on the coin event history. + // recoupGroup.scheduleRefreshCoins.push({ + // coinPub: oldCoin.coinPub, + // amount: Amounts.stringify(refreshAmount), + // }); await tx.coins.put(revokedCoin); await tx.coins.put(oldCoin); await putGroupAsFinished(wex, tx, recoupGroup, coinIdx); @@ -300,6 +296,10 @@ export async function processRecoupGroup( wex: WalletExecutionContext, recoupGroupId: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + let recoupGroup = await wex.db.runReadOnlyTx( { storeNames: ["recoupGroups"] }, async (tx) => { @@ -396,12 +396,13 @@ export async function processRecoupGroup( await wex.db.runReadWriteTx( { storeNames: [ - "recoupGroups", "coinAvailability", + "coinHistory", + "coins", "denominations", + "recoupGroups", "refreshGroups", "refreshSessions", - "coins", ], }, async (tx) => { diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts index 05c65f6b6..f782d2445 100644 --- a/packages/taler-wallet-core/src/refresh.ts +++ b/packages/taler-wallet-core/src/refresh.ts @@ -54,7 +54,6 @@ import { makeErrorDetail, NotificationType, RefreshReason, - TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, @@ -93,6 +92,7 @@ import { import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js"; import { CoinAvailabilityRecord, + CoinHistoryRecord, CoinRecord, CoinSourceType, DenominationRecord, @@ -1211,7 +1211,6 @@ async function refreshReveal( coinEvHash: pc.coinEvHash, maxAge: pc.maxAge, ageCommitmentProof: pc.ageCommitmentProof, - spendAllocation: undefined, }; coins.push(coin); @@ -1325,6 +1324,10 @@ export async function processRefreshGroup( wex: WalletExecutionContext, refreshGroupId: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + logger.trace(`processing refresh group ${refreshGroupId}`); const refreshGroup = await wex.db.runReadOnlyTx( @@ -1345,35 +1348,36 @@ export async function processRefreshGroup( throw Error("refresh blocked"); } - // Process refresh sessions of the group in parallel. logger.trace( `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`, ); let errors: TalerErrorDetail[] = []; let inShutdown = false; - const ps = refreshGroup.oldCoinPubs.map((x, i) => - processRefreshSession(wex, refreshGroupId, i).catch((x) => { + + // Process refresh sessions in sequence. + // In the future, we could parallelize request, in particular when multiple + // exchanges are involved. + // But we need to make sure that we write results to DB with high priority, + // otherwise we run into problems with very large refresh groups, where we'd first + // do many many network requests before even going to the DB. + + for (let i = 0; i < refreshGroup.oldCoinPubs.length; i++) { + try { + await processRefreshSession(wex, refreshGroupId, i); + } catch (x) { if (x instanceof CryptoApiStoppedError) { inShutdown = true; logger.info( "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.", ); - return; - } - if (x instanceof TalerError) { - logger.warn("process refresh session got exception (TalerError)"); - logger.warn(`exc ${x}`); - logger.warn(`exc stack ${x.stack}`); - logger.warn(`error detail: ${j2s(x.errorDetail)}`); - } else { - logger.warn("process refresh session got exception"); - logger.warn(`exc ${x}`); - logger.warn(`exc stack ${x.stack}`); + break; } - errors.push(getErrorDetailFromException(x)); - }), - ); - await Promise.all(ps); + const err = getErrorDetailFromException(x); + logger.warn(`exception in refresh session: ${j2s(err)}`); + errors.push(getErrorDetailFromException(err)); + } + } + if (inShutdown) { return TaskRunResult.finished(); } @@ -1547,7 +1551,13 @@ export async function calculateRefreshOutput( async function applyRefreshToOldCoins( wex: WalletExecutionContext, tx: WalletDbReadWriteTransaction< - ["denominations", "coins", "refreshGroups", "coinAvailability"] + [ + "denominations", + "coins", + "coinHistory", + "refreshGroups", + "coinAvailability", + ] >, oldCoinPubs: CoinRefreshRequest[], refreshGroupId: string, @@ -1605,16 +1615,24 @@ async function applyRefreshToOldCoins( default: assertUnreachable(coin.status); } - if (!coin.spendAllocation) { - coin.spendAllocation = { - amount: Amounts.stringify(ocp.amount), - // id: `txn:refresh:${refreshGroupId}`, - id: constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }), + let histEntry: CoinHistoryRecord | undefined = await tx.coinHistory.get( + coin.coinPub, + ); + if (!histEntry) { + histEntry = { + coinPub: coin.coinPub, + history: [], }; } + histEntry.history.push({ + type: "refresh", + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }), + amount: Amounts.stringify(ocp.amount), + }); + await tx.coinHistory.put(histEntry); await tx.coins.put(coin); } } @@ -1639,6 +1657,7 @@ export async function createRefreshGroup( [ "denominations", "coins", + "coinHistory", "refreshGroups", "refreshSessions", "coinAvailability", @@ -1796,6 +1815,7 @@ export async function forceRefresh( "refreshSessions", "denominations", "coins", + "coinHistory", ], }, async (tx) => { diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts index 470f45aff..434ee8635 100644 --- a/packages/taler-wallet-core/src/shepherd.ts +++ b/packages/taler-wallet-core/src/shepherd.ts @@ -145,6 +145,14 @@ function taskGivesLiveness(taskId: string): boolean { } export interface TaskScheduler { + /** + * Ensure that the task scheduler is running. + * + * If it is not running, start it, with previous + * tasks loaded from the database. + * + * Returns after the scheduler is running. + */ ensureRunning(): Promise<void>; startShepherdTask(taskId: TaskIdStr): void; stopShepherdTask(taskId: TaskIdStr): void; @@ -188,6 +196,9 @@ export class TaskSchedulerImpl implements TaskScheduler { } } + /** + * @see TaskScheduler.ensureRunning + */ async ensureRunning(): Promise<void> { if (this.isRunning) { return; @@ -261,7 +272,7 @@ export class TaskSchedulerImpl implements TaskScheduler { const tasksIds = [...this.sheps.keys()]; logger.info(`reloading shepherd with ${tasksIds.length} tasks`); for (const taskId of tasksIds) { - await this.stopShepherdTask(taskId); + this.stopShepherdTask(taskId); } for (const taskId of tasksIds) { this.startShepherdTask(taskId); @@ -276,9 +287,10 @@ export class TaskSchedulerImpl implements TaskScheduler { return; } logger.trace( - `Waiting old task to complete the loop in cancel mode ${taskId}`, + `Waiting for old task to complete the loop in cancel mode ${taskId}`, ); await oldShep.latch; + logger.trace(`Old task ${taskId} completed in cancel mode`); } logger.trace(`Creating new shepherd for ${taskId}`); const newShep: ShepherdInfo = { @@ -464,6 +476,14 @@ export class TaskSchedulerImpl implements TaskScheduler { } break; } + case TaskRunResultType.NetworkRequired: { + logger.trace(`Shepherd for ${taskId} got network-required result.`); + await storePendingTaskPending(this.ws, taskId); + const delay = Duration.getForever(); + logger.trace(`Not retrying task until network is restored.`); + await this.wait(taskId, info, delay); + break; + } default: assertUnreachable(res); } @@ -613,6 +633,15 @@ async function callOperationHandlerForTaskId( taskId: TaskIdStr, ): Promise<TaskRunResult> { const pending = parseTaskIdentifier(taskId); + + const txId = convertTaskToTransactionId(taskId); + if (txId) { + wex.oc.observe({ + type: ObservabilityEventType.DeclareConcernsTransaction, + transactionId: txId, + }); + } + switch (pending.tag) { case PendingTaskType.ExchangeUpdate: return await updateExchangeFromUrlHandler(wex, pending.exchangeBaseUrl); @@ -843,91 +872,6 @@ async function makeExchangeRetryNotification( return notif; } -export function listTaskForTransactionId(transactionId: string): TaskIdStr[] { - const tid = parseTransactionIdentifier(transactionId); - if (!tid) { - throw Error("invalid task ID"); - } - switch (tid.tag) { - case TransactionType.Deposit: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.Deposit, - depositGroupId: tid.depositGroupId, - }), - ]; - case TransactionType.InternalWithdrawal: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.Withdraw, - withdrawalGroupId: tid.withdrawalGroupId, - }), - ]; - case TransactionType.Payment: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId: tid.proposalId, - }), - ]; - case TransactionType.PeerPullCredit: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.PeerPullCredit, - pursePub: tid.pursePub, - }), - ]; - case TransactionType.PeerPullDebit: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.PeerPullDebit, - peerPullDebitId: tid.peerPullDebitId, - }), - ]; - case TransactionType.PeerPushCredit: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.PeerPullCredit, - pursePub: tid.peerPushCreditId, - }), - ]; - case TransactionType.PeerPushDebit: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.PeerPullCredit, - pursePub: tid.pursePub, - }), - ]; - case TransactionType.Recoup: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.Recoup, - recoupGroupId: tid.recoupGroupId, - }), - ]; - case TransactionType.Refresh: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.Refresh, - refreshGroupId: tid.refreshGroupId, - }), - ]; - case TransactionType.Refund: - return []; - case TransactionType.Withdrawal: - return [ - constructTaskIdentifier({ - tag: PendingTaskType.Withdraw, - withdrawalGroupId: tid.withdrawalGroupId, - }), - ]; - case TransactionType.DenomLoss: - return []; - default: - assertUnreachable(tid); - } -} - /** * Convert the task ID for a task that processes a transaction int * the ID for the transaction. diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts index 899c4a8b2..6435595cb 100644 --- a/packages/taler-wallet-core/src/testing.ts +++ b/packages/taler-wallet-core/src/testing.ts @@ -58,7 +58,10 @@ import { import { getBalances } from "./balance.js"; import { genericWaitForState } from "./common.js"; import { createDepositGroup } from "./deposits.js"; -import { fetchFreshExchange } from "./exchanges.js"; +import { + acceptExchangeTermsOfService, + fetchFreshExchange, +} from "./exchanges.js"; import { confirmPay, preparePayForUri, @@ -122,6 +125,9 @@ export async function withdrawTestBalance( amount, ); + await fetchFreshExchange(wex, req.exchangeBaseUrl); + await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl); + const acceptResp = await acceptWithdrawalFromUri(wex, { talerWithdrawUri: wresp.taler_withdraw_uri, selectedExchange: exchangeBaseUrl, @@ -410,6 +416,7 @@ export async function waitUntilAllTransactionsFinal( switch (notif.newTxState.major) { case TransactionMajorState.Pending: case TransactionMajorState.Aborting: + case TransactionMajorState.Finalizing: return false; default: return true; @@ -424,6 +431,7 @@ export async function waitUntilAllTransactionsFinal( switch (tx.txState.major) { case TransactionMajorState.Pending: case TransactionMajorState.Aborting: + case TransactionMajorState.Finalizing: case TransactionMajorState.Suspended: case TransactionMajorState.SuspendedAborting: logger.info( @@ -497,6 +505,7 @@ export async function waitUntilGivenTransactionsFinal( } switch (tx.txState.major) { case TransactionMajorState.Pending: + case TransactionMajorState.Finalizing: case TransactionMajorState.Aborting: case TransactionMajorState.Suspended: case TransactionMajorState.SuspendedAborting: @@ -542,6 +551,7 @@ export async function waitUntilRefreshesDone( } switch (tx.txState.major) { case TransactionMajorState.Pending: + case TransactionMajorState.Finalizing: case TransactionMajorState.Aborting: case TransactionMajorState.Suspended: case TransactionMajorState.SuspendedAborting: diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index 7782d09ba..0649f9ce2 100644 --- a/packages/taler-wallet-core/src/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -99,7 +99,7 @@ import { computePayMerchantTransactionActions, computePayMerchantTransactionState, computeRefundTransactionState, - expectProposalDownload, + expectProposalDownloadInTx, extractContractData, PayMerchantTransactionContext, RefundTransactionContext, @@ -306,7 +306,7 @@ export async function getTransactionById( async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) throw Error("not found"); - const download = await expectProposalDownload(wex, purchase, tx); + const download = await expectProposalDownloadInTx(wex, tx, purchase); const contractData = download.contractData; const payOpId = TaskIdentifiers.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); @@ -744,7 +744,10 @@ function buildTransactionForBankIntegratedWithdraw( ? undefined : Amounts.currencyOf(wg.instructedAmount); const currency = wg.wgInfo.bankInfo.currency ?? instructedCurrency; - checkDbInvariant(currency !== undefined, "wg uninitialized (missing currency)"); + checkDbInvariant( + currency !== undefined, + "wg uninitialized (missing currency)", + ); const txState = computeWithdrawalTransactionStatus(wg); const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); @@ -765,7 +768,10 @@ function buildTransactionForBankIntegratedWithdraw( confirmed: wg.wgInfo.bankInfo.timestampBankConfirmed ? true : false, exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts, reservePub: wg.reservePub, - bankConfirmationUrl: wg.wgInfo.bankInfo.confirmUrl, + bankConfirmationUrl: wg.wgInfo.bankInfo.externalConfirmation + ? undefined + : wg.wgInfo.bankInfo.confirmUrl, + externalConfirmation: wg.wgInfo.bankInfo.externalConfirmation, reserveIsReady: wg.status === WithdrawalGroupStatus.Done || wg.status === WithdrawalGroupStatus.PendingReady, @@ -1812,6 +1818,13 @@ export async function retryAll(wex: WalletExecutionContext): Promise<void> { } } +/** + * Restart all the running tasks. + */ +export async function restartAll(wex: WalletExecutionContext): Promise<void> { + await wex.taskScheduler.reload(); +} + async function getContextForTransaction( wex: WalletExecutionContext, transactionId: string, diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts index 8c1ac5fc2..8b4b24351 100644 --- a/packages/taler-wallet-core/src/versions.ts +++ b/packages/taler-wallet-core/src/versions.ts @@ -29,13 +29,6 @@ export const WALLET_EXCHANGE_PROTOCOL_VERSION = "17:0:0"; export const WALLET_MERCHANT_PROTOCOL_VERSION = "5:0:1"; /** - * Protocol version spoken with the bank (bank integration API). - * - * Uses libtool's current:revision:age versioning. - */ -export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "1:0:0"; - -/** * Protocol version spoken with the bank (corebank API). * * Uses libtool's current:revision:age versioning. diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index aa88331ea..12abb6469 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -59,6 +59,7 @@ import { DeleteExchangeRequest, DeleteStoredBackupRequest, DeleteTransactionRequest, + EmptyObject, ExchangeDetailedResponse, ExchangesListResponse, ExchangesShortListResponse, @@ -71,6 +72,8 @@ import { GetContractTermsDetailsRequest, GetCurrencySpecificationRequest, GetCurrencySpecificationResponse, + GetDepositWireTypesForCurrencyRequest, + GetDepositWireTypesForCurrencyResponse, GetExchangeEntryByUrlRequest, GetExchangeEntryByUrlResponse, GetExchangeResourcesRequest, @@ -79,6 +82,8 @@ import { GetExchangeTosResult, GetPlanForOperationRequest, GetPlanForOperationResponse, + GetQrCodesForPaytoRequest, + GetQrCodesForPaytoResponse, GetWithdrawalDetailsForAmountRequest, GetWithdrawalDetailsForUriRequest, HintNetworkAvailabilityRequest, @@ -90,6 +95,7 @@ import { InitiatePeerPushDebitRequest, InitiatePeerPushDebitResponse, IntegrationTestArgs, + IntegrationTestV2Args, KnownBankAccounts, ListAssociatedRefreshesRequest, ListAssociatedRefreshesResponse, @@ -127,8 +133,6 @@ import { TestPayResult, TestingGetDenomStatsRequest, TestingGetDenomStatsResponse, - TestingListTasksForTransactionRequest, - TestingListTasksForTransactionsResponse, TestingSetTimetravelRequest, TestingWaitTransactionRequest, Transaction, @@ -190,18 +194,17 @@ export enum WalletApiOperation { GetBalances = "getBalances", GetBalanceDetail = "getBalanceDetail", GetPlanForOperation = "getPlanForOperation", - ConvertDepositAmount = "ConvertDepositAmount", - GetMaxDepositAmount = "GetMaxDepositAmount", + ConvertDepositAmount = "convertDepositAmount", + GetMaxDepositAmount = "getMaxDepositAmount", ConvertPeerPushAmount = "ConvertPeerPushAmount", - GetMaxPeerPushAmount = "GetMaxPeerPushAmount", - ConvertWithdrawalAmount = "ConvertWithdrawalAmount", + GetMaxPeerPushAmount = "getMaxPeerPushAmount", + ConvertWithdrawalAmount = "convertWithdrawalAmount", GetUserAttentionRequests = "getUserAttentionRequests", GetUserAttentionUnreadCount = "getUserAttentionUnreadCount", MarkAttentionRequestAsRead = "markAttentionRequestAsRead", - GetPendingOperations = "getPendingOperations", GetActiveTasks = "getActiveTasks", SetExchangeTosAccepted = "setExchangeTosAccepted", - SetExchangeTosForgotten = "SetExchangeTosForgotten", + SetExchangeTosForgotten = "setExchangeTosForgotten", StartRefundQueryForUri = "startRefundQueryForUri", StartRefundQuery = "startRefundQuery", PrepareBankIntegratedWithdrawal = "prepareBankIntegratedWithdrawal", @@ -266,13 +269,13 @@ export enum WalletApiOperation { Shutdown = "shutdown", HintNetworkAvailability = "hintNetworkAvailability", CanonicalizeBaseUrl = "canonicalizeBaseUrl", + GetDepositWireTypesForCurrency = "getDepositWireTypesForCurrency", + GetQrCodesForPayto = "getQrCodesForPayto", TestingWaitTransactionsFinal = "testingWaitTransactionsFinal", TestingWaitRefreshesFinal = "testingWaitRefreshesFinal", TestingWaitTransactionState = "testingWaitTransactionState", TestingWaitTasksDone = "testingWaitTasksDone", TestingSetTimetravel = "testingSetTimetravel", - TestingInfiniteTransactionLoop = "testingInfiniteTransactionLoop", - TestingListTaskForTransaction = "testingListTasksForTransaction", TestingGetDenomStats = "testingGetDenomStats", TestingPing = "testingPing", TestingGetReserveHistory = "testingGetReserveHistory", @@ -281,8 +284,6 @@ export enum WalletApiOperation { // group: Initialization -type EmptyObject = Record<string, never>; - /** * Initialize wallet-core. * @@ -729,6 +730,16 @@ export type GetExchangeTosOp = { }; /** + * Get wire types that can be used for a deposit operation + * with the provided currency. + */ +export type GetDepositWireTypesForCurrencyOp = { + op: WalletApiOperation.GetDepositWireTypesForCurrency; + request: GetDepositWireTypesForCurrencyRequest; + response: GetDepositWireTypesForCurrencyResponse; +}; + +/** * Get the current terms of a service of an exchange. */ export type GetExchangeDetailedInfoOp = { @@ -986,6 +997,12 @@ export type CanonicalizeBaseUrlOp = { response: CanonicalizeBaseUrlResponse; }; +export type GetQrCodesForPaytoOp = { + op: WalletApiOperation.GetQrCodesForPayto; + request: GetQrCodesForPaytoRequest; + response: GetQrCodesForPaytoResponse; +}; + // group: Database Management /** @@ -1051,7 +1068,7 @@ export type RunIntegrationTestOp = { */ export type RunIntegrationTestV2Op = { op: WalletApiOperation.RunIntegrationTestV2; - request: IntegrationTestArgs; + request: IntegrationTestV2Args; response: EmptyObject; }; @@ -1120,17 +1137,6 @@ export type GetUserAttentionsUnreadCount = { response: UserAttentionsCountResponse; }; -/** - * Get wallet-internal pending tasks. - * - * @deprecated - */ -export type GetPendingTasksOp = { - op: WalletApiOperation.GetPendingOperations; - request: EmptyObject; - response: any; -}; - export type GetActiveTasksOp = { op: WalletApiOperation.GetActiveTasks; request: EmptyObject; @@ -1156,15 +1162,6 @@ export type TestingSetTimetravelOp = { }; /** - * Add an offset to the wallet's internal time. - */ -export type TestingListTasksForTransactionOp = { - op: WalletApiOperation.TestingListTaskForTransaction; - request: TestingListTasksForTransactionRequest; - response: TestingListTasksForTransactionsResponse; -}; - -/** * Wait until all transactions are in a final state. */ export type TestingWaitTransactionsFinalOp = { @@ -1279,7 +1276,6 @@ export type WalletOperations = { [WalletApiOperation.GetTransactionById]: GetTransactionByIdOp; [WalletApiOperation.GetWithdrawalTransactionByUri]: GetWithdrawalTransactionByUriOp; [WalletApiOperation.RetryPendingNow]: RetryPendingNowOp; - [WalletApiOperation.GetPendingOperations]: GetPendingTasksOp; [WalletApiOperation.GetActiveTasks]: GetActiveTasksOp; [WalletApiOperation.GetUserAttentionRequests]: GetUserAttentionRequests; [WalletApiOperation.GetUserAttentionUnreadCount]: GetUserAttentionsUnreadCount; @@ -1348,7 +1344,6 @@ export type WalletOperations = { [WalletApiOperation.RecoverStoredBackup]: RecoverStoredBackupsOp; [WalletApiOperation.UpdateExchangeEntry]: UpdateExchangeEntryOp; [WalletApiOperation.PrepareWithdrawExchange]: PrepareWithdrawExchangeOp; - [WalletApiOperation.TestingInfiniteTransactionLoop]: any; [WalletApiOperation.DeleteExchange]: DeleteExchangeOp; [WalletApiOperation.GetExchangeResources]: GetExchangeResourcesOp; [WalletApiOperation.ListGlobalCurrencyAuditors]: ListGlobalCurrencyAuditorsOp; @@ -1358,7 +1353,6 @@ export type WalletOperations = { [WalletApiOperation.AddGlobalCurrencyExchange]: AddGlobalCurrencyExchangeOp; [WalletApiOperation.RemoveGlobalCurrencyExchange]: RemoveGlobalCurrencyExchangeOp; [WalletApiOperation.ListAssociatedRefreshes]: ListAssociatedRefreshesOp; - [WalletApiOperation.TestingListTaskForTransaction]: TestingListTasksForTransactionOp; [WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp; [WalletApiOperation.TestingPing]: TestingPingOp; [WalletApiOperation.Shutdown]: ShutdownOp; @@ -1368,6 +1362,8 @@ export type WalletOperations = { [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp; [WalletApiOperation.TestingResetAllRetries]: TestingResetAllRetriesOp; [WalletApiOperation.HintNetworkAvailability]: HintNetworkAvailabilityOp; + [WalletApiOperation.GetDepositWireTypesForCurrency]: GetDepositWireTypesForCurrencyOp; + [WalletApiOperation.GetQrCodesForPayto]: GetQrCodesForPaytoOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index f1d53b7d5..5b3b4da29 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -24,25 +24,56 @@ */ import { IDBDatabase, IDBFactory } from "@gnu-taler/idb-bridge"; import { + AbortTransactionRequest, AbsoluteTime, + AcceptBankIntegratedWithdrawalRequest, + AcceptManualWithdrawalRequest, + AcceptManualWithdrawalResult, + AcceptWithdrawalResponse, ActiveTask, + AddExchangeRequest, + AddGlobalCurrencyAuditorRequest, + AddGlobalCurrencyExchangeRequest, + AddKnownBankAccountsRequest, AmountJson, AmountString, Amounts, - AsyncCondition, CancellationToken, + CanonicalizeBaseUrlRequest, + CanonicalizeBaseUrlResponse, + Codec, CoinDumpJson, CoinStatus, + ConfirmPayRequest, + ConfirmPayResult, CoreApiResponse, CreateStoredBackupResponse, + DeleteExchangeRequest, DeleteStoredBackupRequest, DenominationInfo, Duration, + EmptyObject, ExchangesShortListResponse, + FailTransactionRequest, + ForgetKnownBankAccountsRequest, + GetActiveTasksResponse, + GetContractTermsDetailsRequest, + GetCurrencySpecificationRequest, GetCurrencySpecificationResponse, + GetDepositWireTypesForCurrencyRequest, + GetDepositWireTypesForCurrencyResponse, + GetExchangeTosRequest, + GetExchangeTosResult, + GetQrCodesForPaytoRequest, + GetQrCodesForPaytoResponse, + HintNetworkAvailabilityRequest, + InitRequest, InitResponse, + IntegrationTestArgs, + IntegrationTestV2Args, KnownBankAccounts, KnownBankAccountsInfo, + ListExchangesForScopedCurrencyRequest, ListGlobalCurrencyAuditorsResponse, ListGlobalCurrencyExchangesResponse, Logger, @@ -55,21 +86,35 @@ import { PrepareWithdrawExchangeRequest, PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, + RemoveGlobalCurrencyAuditorRequest, + RemoveGlobalCurrencyExchangeRequest, + SharePaymentRequest, + SharePaymentResult, + StartRefundQueryRequest, StoredBackupList, + SuspendTransactionRequest, + TalerBankIntegrationHttpClient, TalerError, TalerErrorCode, TalerProtocolTimestamp, TalerUriAction, + TestingGetDenomStatsRequest, TestingGetDenomStatsResponse, - TestingListTasksForTransactionsResponse, + TestingGetReserveHistoryRequest, + TestingSetTimetravelRequest, TestingWaitTransactionRequest, TimerAPI, TimerGroup, TransactionType, + TransactionsResponse, + UpdateExchangeEntryRequest, + ValidateIbanRequest, ValidateIbanResponse, + WalletContractData, WalletCoreVersion, WalletNotification, WalletRunConfig, + WithdrawTestBalanceRequest, canonicalizeBaseUrl, checkDbInvariant, codecForAbortTransaction, @@ -95,6 +140,7 @@ import { codecForDeleteExchangeRequest, codecForDeleteStoredBackupRequest, codecForDeleteTransactionRequest, + codecForEmptyObject, codecForFailTransactionRequest, codecForForceRefreshRequest, codecForForgetKnownBankAccounts, @@ -102,9 +148,11 @@ import { codecForGetBalanceDetailRequest, codecForGetContractTermsDetails, codecForGetCurrencyInfoRequest, + codecForGetDepositWireTypesForCurrencyRequest, codecForGetExchangeEntryByUrlRequest, codecForGetExchangeResourcesRequest, codecForGetExchangeTosRequest, + codecForGetQrCodesForPaytoRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForHintNetworkAvailabilityRequest, @@ -137,7 +185,6 @@ import { codecForTestPayArgs, codecForTestingGetDenomStatsRequest, codecForTestingGetReserveHistoryRequest, - codecForTestingListTasksForTransactionRequest, codecForTestingSetTimetravelRequest, codecForTransactionByIdRequest, codecForTransactionsRequest, @@ -147,13 +194,13 @@ import { codecForValidateIbanRequest, codecForWithdrawTestBalance, getErrorDetailFromException, + getQrCodesForPayto, j2s, openPromise, parsePaytoUri, parseTalerUri, performanceNow, safeStringifyException, - sampleWalletCoreTransactions, setDangerousTimetravel, validateIban, } from "@gnu-taler/taler-util"; @@ -167,6 +214,7 @@ import { markAttentionRequestAsRead, } from "./attention.js"; import { + RunBackupCycleRequest, addBackupProvider, codecForAddBackupProviderRequest, codecForRemoveBackupProvider, @@ -188,6 +236,7 @@ import { CoinSourceType, ConfigRecordKey, DenominationRecord, + WalletDbHelpers, WalletDbReadOnlyTransaction, WalletStoresV1, clearDatabase, @@ -214,6 +263,7 @@ import { getExchangeDetailedInfo, getExchangeResources, getExchangeTos, + getExchangeWireDetailsInTx, listExchanges, lookupExchangeByUri, } from "./exchanges.js"; @@ -267,7 +317,6 @@ import { TaskSchedulerImpl, convertTaskToTransactionId, getActiveTaskIds, - listTaskForTransactionId, } from "./shepherd.js"; import { runIntegrationTest, @@ -288,6 +337,7 @@ import { getTransactions, getWithdrawalTransactionByUri, parseTransactionIdentifier, + restartAll as restartAllRunningTasks, resumeTransaction, retryAll, retryTransaction, @@ -295,7 +345,6 @@ import { } from "./transactions.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_COREBANK_API_PROTOCOL_VERSION, WALLET_CORE_API_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, @@ -304,6 +353,7 @@ import { import { WalletApiOperation, WalletCoreApiClient, + WalletCoreRequestType, WalletCoreResponseType, } from "./wallet-api-types.js"; import { @@ -479,7 +529,10 @@ async function setCoinSuspended( c.denomPubHash, c.maxAge, ]); - checkDbInvariant(!!coinAvailability, `no denom info for ${c.denomPubHash} age ${c.maxAge}`); + checkDbInvariant( + !!coinAvailability, + `no denom info for ${c.denomPubHash} age ${c.maxAge}`, + ); if (suspended) { if (c.status !== CoinStatus.Fresh) { return; @@ -511,7 +564,7 @@ async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> { const coinsJson: CoinDumpJson = { coins: [] }; logger.info("dumping coins"); await wex.db.runReadOnlyTx( - { storeNames: ["coins", "denominations"] }, + { storeNames: ["coins", "coinHistory", "denominations"] }, async (tx) => { const coins = await tx.coins.iter().toArray(); for (const c of coins) { @@ -542,22 +595,18 @@ async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> { logger.warn("no denomination found for coin"); continue; } + const historyRec = await tx.coinHistory.get(c.coinPub); coinsJson.coins.push({ - coin_pub: c.coinPub, - denom_pub: denomInfo.denomPub, - denom_pub_hash: c.denomPubHash, - denom_value: denom.value, - exchange_base_url: c.exchangeBaseUrl, - refresh_parent_coin_pub: refreshParentCoinPub, - withdrawal_reserve_pub: withdrawalReservePub, - coin_status: c.status, + coinPub: c.coinPub, + denomPub: denomInfo.denomPub, + denomPubHash: c.denomPubHash, + denomValue: denom.value, + exchangeBaseUrl: c.exchangeBaseUrl, + refreshParentCoinPub: refreshParentCoinPub, + withdrawalReservePub: withdrawalReservePub, + coinStatus: c.status, ageCommitmentProof: c.ageCommitmentProof, - spend_allocation: c.spendAllocation - ? { - amount: c.spendAllocation.amount, - id: c.spendAllocation.id, - } - : undefined, + history: historyRec ? historyRec.history : [], }); } }, @@ -677,18 +726,728 @@ async function handlePrepareWithdrawExchange( }; } -/** - * Response returned from the pending operations API. - * - * @deprecated this is a placeholder for the response type of a deprecated wallet-core request. - */ -export interface PendingOperationsResponse { - /** - * List of pending operations. - */ - pendingOperations: any[]; +async function handleRetryPendingNow( + wex: WalletExecutionContext, +): Promise<EmptyObject> { + logger.error("retryPendingNow currently not implemented"); + return {}; +} + +async function handleSharePayment( + wex: WalletExecutionContext, + req: SharePaymentRequest, +): Promise<SharePaymentResult> { + return await sharePayment(wex, req.merchantBaseUrl, req.orderId); +} + +async function handleDeleteStoredBackup( + wex: WalletExecutionContext, + req: DeleteStoredBackupRequest, +): Promise<EmptyObject> { + await deleteStoredBackup(wex, req); + return {}; +} + +async function handleRecoverStoredBackup( + wex: WalletExecutionContext, + req: RecoverStoredBackupRequest, +): Promise<EmptyObject> { + await recoverStoredBackup(wex, req); + return {}; +} + +async function handleSetWalletRunConfig( + wex: WalletExecutionContext, + req: InitRequest, +) { + if (logger.shouldLogTrace()) { + const initType = wex.ws.initCalled + ? "repeat initialization" + : "first initialization"; + logger.trace(`init request (${initType}): ${j2s(req)}`); + } + + // Write to the DB to make sure that we're failing early in + // case the DB is not writeable. + try { + await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { + tx.config.put({ + key: ConfigRecordKey.LastInitInfo, + value: timestampProtocolToDb(TalerProtocolTimestamp.now()), + }); + }); + } catch (e) { + logger.error("error writing to database during initialization"); + throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, { + innerError: getErrorDetailFromException(e), + }); + } + wex.ws.initWithConfig(applyRunConfigDefaults(req.config)); + + if (wex.ws.config.testing.skipDefaults) { + logger.trace("skipping defaults"); + } else { + logger.trace("filling defaults"); + await fillDefaults(wex); + } + const resp: InitResponse = { + versionInfo: handleGetVersion(wex), + }; + + if (req.config?.lazyTaskLoop) { + logger.trace("lazily starting task loop"); + } else { + await wex.taskScheduler.ensureRunning(); + } + + wex.ws.initCalled = true; + return resp; +} + +async function handleWithdrawTestkudos(wex: WalletExecutionContext) { + await withdrawTestBalance(wex, { + amount: "TESTKUDOS:10" as AmountString, + corebankApiBaseUrl: "https://bank.test.taler.net/", + exchangeBaseUrl: "https://exchange.test.taler.net/", + }); + // FIXME: Is this correct? + return {}; +} + +async function handleWithdrawTestBalance( + wex: WalletExecutionContext, + req: WithdrawTestBalanceRequest, +): Promise<EmptyObject> { + await withdrawTestBalance(wex, req); + return {}; +} + +async function handleRunIntegrationTest( + wex: WalletExecutionContext, + req: IntegrationTestArgs, +): Promise<EmptyObject> { + await runIntegrationTest(wex, req); + return {}; +} + +async function handleRunIntegrationTestV2( + wex: WalletExecutionContext, + req: IntegrationTestV2Args, +): Promise<EmptyObject> { + await runIntegrationTest2(wex, req); + return {}; +} + +async function handleValidateIban( + wex: WalletExecutionContext, + req: ValidateIbanRequest, +): Promise<ValidateIbanResponse> { + const valRes = validateIban(req.iban); + const resp: ValidateIbanResponse = { + valid: valRes.type === "valid", + }; + return resp; +} + +async function handleAddExchange( + wex: WalletExecutionContext, + req: AddExchangeRequest, +): Promise<EmptyObject> { + await fetchFreshExchange(wex, req.exchangeBaseUrl, {}); + return {}; +} + +async function handleUpdateExchangeEntry( + wex: WalletExecutionContext, + req: UpdateExchangeEntryRequest, +): Promise<EmptyObject> { + await fetchFreshExchange(wex, req.exchangeBaseUrl, { + forceUpdate: !!req.force, + }); + return {}; +} + +async function handleTestingGetDenomStats( + wex: WalletExecutionContext, + req: TestingGetDenomStatsRequest, +): Promise<TestingGetDenomStatsResponse> { + const denomStats: TestingGetDenomStatsResponse = { + numKnown: 0, + numLost: 0, + numOffered: 0, + }; + await wex.db.runReadOnlyTx({ storeNames: ["denominations"] }, async (tx) => { + const denoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll( + req.exchangeBaseUrl, + ); + for (const d of denoms) { + denomStats.numKnown++; + if (d.isOffered) { + denomStats.numOffered++; + } + if (d.isLost) { + denomStats.numLost++; + } + } + }); + return denomStats; +} + +async function handleListExchangesForScopedCurrency( + wex: WalletExecutionContext, + req: ListExchangesForScopedCurrencyRequest, +): Promise<ExchangesShortListResponse> { + const exchangesResp = await listExchanges(wex); + const result: ExchangesShortListResponse = { + exchanges: [], + }; + // Right now we only filter on the currency, as wallet-core doesn't + // fully support scoped currencies yet. + for (const exch of exchangesResp.exchanges) { + if (exch.currency === req.scope.currency) { + result.exchanges.push({ + exchangeBaseUrl: exch.exchangeBaseUrl, + }); + } + } + return result; +} + +async function handleAddKnownBankAccount( + wex: WalletExecutionContext, + req: AddKnownBankAccountsRequest, +): Promise<EmptyObject> { + await addKnownBankAccounts(wex, req.payto, req.alias, req.currency); + return {}; +} + +async function handleForgetKnownBankAccounts( + wex: WalletExecutionContext, + req: ForgetKnownBankAccountsRequest, +): Promise<EmptyObject> { + await forgetKnownBankAccounts(wex, req.payto); + return {}; +} + +// FIXME: Doesn't have proper type! +async function handleTestingGetReserveHistory( + wex: WalletExecutionContext, + req: TestingGetReserveHistoryRequest, +): Promise<any> { + const reserve = await wex.db.runReadOnlyTx( + { storeNames: ["reserves"] }, + async (tx) => { + return tx.reserves.indexes.byReservePub.get(req.reservePub); + }, + ); + if (!reserve) { + throw Error("no reserve pub found"); + } + const sigResp = await wex.cryptoApi.signReserveHistoryReq({ + reservePriv: reserve.reservePriv, + startOffset: 0, + }); + const exchangeBaseUrl = req.exchangeBaseUrl; + const url = new URL(`reserves/${req.reservePub}/history`, exchangeBaseUrl); + const resp = await wex.http.fetch(url.href, { + headers: { ["Taler-Reserve-History-Signature"]: sigResp.sig }, + }); + const historyJson = await readSuccessResponseJsonOrThrow(resp, codecForAny()); + return historyJson; +} + +async function handleAcceptManualWithdrawal( + wex: WalletExecutionContext, + req: AcceptManualWithdrawalRequest, +): Promise<AcceptManualWithdrawalResult> { + const res = await createManualWithdrawal(wex, { + amount: Amounts.parseOrThrow(req.amount), + exchangeBaseUrl: req.exchangeBaseUrl, + restrictAge: req.restrictAge, + forceReservePriv: req.forceReservePriv, + }); + return res; +} + +async function handleGetExchangeTos( + wex: WalletExecutionContext, + req: GetExchangeTosRequest, +): Promise<GetExchangeTosResult> { + return getExchangeTos( + wex, + req.exchangeBaseUrl, + req.acceptedFormat, + req.acceptLanguage, + ); +} + +async function handleGetContractTermsDetails( + wex: WalletExecutionContext, + req: GetContractTermsDetailsRequest, +): Promise<WalletContractData> { + if (req.proposalId) { + // FIXME: deprecated path + return getContractTermsDetails(wex, req.proposalId); + } + if (req.transactionId) { + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (parsedTx?.tag === TransactionType.Payment) { + return getContractTermsDetails(wex, parsedTx.proposalId); + } + throw Error("transactionId is not a payment transaction"); + } + throw Error("transactionId missing"); +} + +async function handleGetQrCodesForPayto( + wex: WalletExecutionContext, + req: GetQrCodesForPaytoRequest, +): Promise<GetQrCodesForPaytoResponse> { + return { + codes: getQrCodesForPayto(req.paytoUri), + }; +} + +async function handleConfirmPay( + wex: WalletExecutionContext, + req: ConfirmPayRequest, +): Promise<ConfirmPayResult> { + let transactionId; + if (req.proposalId) { + // legacy client support + transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: req.proposalId, + }); + } else if (req.transactionId) { + transactionId = req.transactionId; + } else { + throw Error("transactionId or (deprecated) proposalId required"); + } + return await confirmPay(wex, transactionId, req.sessionId); +} + +async function handleAbortTransaction( + wex: WalletExecutionContext, + req: AbortTransactionRequest, +): Promise<EmptyObject> { + await abortTransaction(wex, req.transactionId); + return {}; +} + +async function handleSuspendTransaction( + wex: WalletExecutionContext, + req: SuspendTransactionRequest, +): Promise<EmptyObject> { + await suspendTransaction(wex, req.transactionId); + return {}; +} + +async function handleGetActiveTasks( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<GetActiveTasksResponse> { + const allTasksId = (await getActiveTaskIds(wex.ws)).taskIds; + + const tasksInfo = await Promise.all( + allTasksId.map(async (id) => { + return await wex.db.runReadOnlyTx( + { storeNames: ["operationRetries"] }, + async (tx) => { + return tx.operationRetries.get(id); + }, + ); + }), + ); + + const tasks = allTasksId.map((taskId, i): ActiveTask => { + const transaction = convertTaskToTransactionId(taskId); + const d = tasksInfo[i]; + + const firstTry = !d + ? undefined + : timestampAbsoluteFromDb(d.retryInfo.firstTry); + const nextTry = !d + ? undefined + : timestampAbsoluteFromDb(d.retryInfo.nextRetry); + const counter = d?.retryInfo.retryCounter; + const lastError = d?.lastError; + + return { + taskId: taskId, + retryCounter: counter, + firstTry, + nextTry, + lastError, + transaction, + }; + }); + return { tasks }; +} + +async function handleFailTransaction( + wex: WalletExecutionContext, + req: FailTransactionRequest, +): Promise<EmptyObject> { + await failTransaction(wex, req.transactionId); + return {}; +} + +async function handleTestingGetSampleTransactions( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<TransactionsResponse> { + // FIXME! + return { transactions: [] }; + // These are out of date! + //return { transactions: sampleWalletCoreTransactions }; +} + +async function handleStartRefundQuery( + wex: WalletExecutionContext, + req: StartRefundQueryRequest, +): Promise<EmptyObject> { + const txIdParsed = parseTransactionIdentifier(req.transactionId); + if (!txIdParsed) { + throw Error("invalid transaction ID"); + } + if (txIdParsed.tag !== TransactionType.Payment) { + throw Error("expected payment transaction ID"); + } + await startQueryRefund(wex, txIdParsed.proposalId); + return {}; +} + +async function handleAddBackupProvider( + wex: WalletExecutionContext, + req: RunBackupCycleRequest, +): Promise<EmptyObject> { + await runBackupCycle(wex, req); + return {}; +} + +async function handleHintNetworkAvailability( + wex: WalletExecutionContext, + req: HintNetworkAvailabilityRequest, +): Promise<EmptyObject> { + wex.ws.networkAvailable = req.isNetworkAvailable; + // When network becomes available, restart tasks as they're blocked + // waiting for the network. + // When network goes down, restart tasks so they notice the network + // is down and wait. + await restartAllRunningTasks(wex); + return {}; +} + +async function handleGetDepositWireTypesForCurrency( + wex: WalletExecutionContext, + req: GetDepositWireTypesForCurrencyRequest, +): Promise<GetDepositWireTypesForCurrencyResponse> { + const wtSet: Set<string> = new Set(); + await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "exchangeDetails"] }, + async (tx) => { + const exchanges = await tx.exchanges.getAll(); + for (const exchange of exchanges) { + const det = await getExchangeWireDetailsInTx(tx, exchange.baseUrl); + if (!det) { + continue; + } + if (det.currency !== req.currency) { + continue; + } + for (const acc of det.wireInfo.accounts) { + let usable = true; + for (const dr of acc.debit_restrictions) { + if (dr.type === "deny") { + usable = false; + break; + } + } + if (!usable) { + break; + } + const parsedPayto = parsePaytoUri(acc.payto_uri); + if (!parsedPayto) { + continue; + } + wtSet.add(parsedPayto.targetType); + } + } + }, + ); + return { + wireTypes: [...wtSet], + }; +} + +async function handleListGlobalCurrencyExchanges( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<ListGlobalCurrencyExchangesResponse> { + const resp: ListGlobalCurrencyExchangesResponse = { + exchanges: [], + }; + await wex.db.runReadOnlyTx( + { storeNames: ["globalCurrencyExchanges"] }, + async (tx) => { + const gceList = await tx.globalCurrencyExchanges.iter().toArray(); + for (const gce of gceList) { + resp.exchanges.push({ + currency: gce.currency, + exchangeBaseUrl: gce.exchangeBaseUrl, + exchangeMasterPub: gce.exchangeMasterPub, + }); + } + }, + ); + return resp; +} + +async function handleListGlobalCurrencyAuditors( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<ListGlobalCurrencyAuditorsResponse> { + const resp: ListGlobalCurrencyAuditorsResponse = { + auditors: [], + }; + await wex.db.runReadOnlyTx( + { storeNames: ["globalCurrencyAuditors"] }, + async (tx) => { + const gcaList = await tx.globalCurrencyAuditors.iter().toArray(); + for (const gca of gcaList) { + resp.auditors.push({ + currency: gca.currency, + auditorBaseUrl: gca.auditorBaseUrl, + auditorPub: gca.auditorPub, + }); + } + }, + ); + return resp; +} + +async function handleAddGlobalCurrencyExchange( + wex: WalletExecutionContext, + req: AddGlobalCurrencyExchangeRequest, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx( + { storeNames: ["globalCurrencyExchanges"] }, + async (tx) => { + const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub]; + const existingRec = + await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( + key, + ); + if (existingRec) { + return; + } + wex.ws.exchangeCache.clear(); + await tx.globalCurrencyExchanges.add({ + currency: req.currency, + exchangeBaseUrl: req.exchangeBaseUrl, + exchangeMasterPub: req.exchangeMasterPub, + }); + }, + ); + return {}; +} + +async function handleRemoveGlobalCurrencyAuditor( + wex: WalletExecutionContext, + req: RemoveGlobalCurrencyAuditorRequest, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx( + { storeNames: ["globalCurrencyAuditors"] }, + async (tx) => { + const key = [req.currency, req.auditorBaseUrl, req.auditorPub]; + const existingRec = + await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(key); + if (!existingRec) { + return; + } + checkDbInvariant(!!existingRec.id, `no global currency for ${j2s(key)}`); + await tx.globalCurrencyAuditors.delete(existingRec.id); + wex.ws.exchangeCache.clear(); + }, + ); + return {}; +} + +async function handleRemoveGlobalCurrencyExchange( + wex: WalletExecutionContext, + req: RemoveGlobalCurrencyExchangeRequest, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx( + { storeNames: ["globalCurrencyExchanges"] }, + async (tx) => { + const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub]; + const existingRec = + await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( + key, + ); + if (!existingRec) { + return; + } + wex.ws.exchangeCache.clear(); + checkDbInvariant(!!existingRec.id, `no global exchange for ${j2s(key)}`); + await tx.globalCurrencyExchanges.delete(existingRec.id); + }, + ); + return {}; +} + +async function handleAddGlobalCurrencyAuditor( + wex: WalletExecutionContext, + req: AddGlobalCurrencyAuditorRequest, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx( + { storeNames: ["globalCurrencyAuditors"] }, + async (tx) => { + const key = [req.currency, req.auditorBaseUrl, req.auditorPub]; + const existingRec = + await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(key); + if (existingRec) { + return; + } + await tx.globalCurrencyAuditors.add({ + currency: req.currency, + auditorBaseUrl: req.auditorBaseUrl, + auditorPub: req.auditorPub, + }); + wex.ws.exchangeCache.clear(); + }, + ); + return {}; +} + +async function handleShutdown( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<EmptyObject> { + wex.ws.stop(); + return {}; +} + +async function handleTestingSetTimetravel( + wex: WalletExecutionContext, + req: TestingSetTimetravelRequest, +): Promise<EmptyObject> { + setDangerousTimetravel(req.offsetMs); + await wex.taskScheduler.reload(); + return {}; +} + +async function handleCanonicalizeBaseUrl( + wex: WalletExecutionContext, + req: CanonicalizeBaseUrlRequest, +): Promise<CanonicalizeBaseUrlResponse> { + return { + url: canonicalizeBaseUrl(req.url), + }; +} + +async function handleDeleteExchange( + wex: WalletExecutionContext, + req: DeleteExchangeRequest, +): Promise<EmptyObject> { + await deleteExchange(wex, req); + return {}; +} + +async function handleCreateStoredBackup( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<CreateStoredBackupResponse> { + return await createStoredBackup(wex); +} + +async function handleAcceptBankIntegratedWithdrawal( + wex: WalletExecutionContext, + req: AcceptBankIntegratedWithdrawalRequest, +): Promise<AcceptWithdrawalResponse> { + return await acceptWithdrawalFromUri(wex, { + selectedExchange: req.exchangeBaseUrl, + talerWithdrawUri: req.talerWithdrawUri, + forcedDenomSel: req.forcedDenomSel, + restrictAge: req.restrictAge, + amount: req.amount, + }); +} + +interface HandlerWithValidator<Tag extends WalletApiOperation> { + codec: Codec<WalletCoreRequestType<Tag>>; + handler: ( + wex: WalletExecutionContext, + req: WalletCoreRequestType<Tag>, + ) => Promise<WalletCoreResponseType<Tag>>; } +// @ts-ignore +const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { + [WalletApiOperation.AbortTransaction]: { + codec: codecForAbortTransaction(), + handler: handleAbortTransaction, + }, + [WalletApiOperation.CreateStoredBackup]: { + codec: codecForEmptyObject(), + handler: handleCreateStoredBackup, + }, + [WalletApiOperation.DeleteStoredBackup]: { + codec: codecForDeleteStoredBackupRequest(), + handler: handleDeleteStoredBackup, + }, + [WalletApiOperation.ListStoredBackups]: { + codec: codecForEmptyObject(), + handler: listStoredBackups, + }, + [WalletApiOperation.SetWalletRunConfig]: { + codec: codecForInitRequest(), + handler: handleSetWalletRunConfig, + }, + // Alias for SetWalletRunConfig + [WalletApiOperation.InitWallet]: { + codec: codecForInitRequest(), + handler: handleSetWalletRunConfig, + }, + [WalletApiOperation.RecoverStoredBackup]: { + codec: codecForRecoverStoredBackupRequest(), + handler: handleRecoverStoredBackup, + }, + [WalletApiOperation.WithdrawTestkudos]: { + codec: codecForEmptyObject(), + handler: handleWithdrawTestkudos, + }, + [WalletApiOperation.WithdrawTestBalance]: { + codec: codecForWithdrawTestBalance(), + handler: handleWithdrawTestBalance, + }, + [WalletApiOperation.RunIntegrationTest]: { + codec: codecForIntegrationTestArgs(), + handler: handleRunIntegrationTest, + }, + [WalletApiOperation.RunIntegrationTestV2]: { + codec: codecForIntegrationTestV2Args(), + handler: handleRunIntegrationTestV2, + }, + [WalletApiOperation.ValidateIban]: { + codec: codecForValidateIbanRequest(), + handler: handleValidateIban, + }, + [WalletApiOperation.TestPay]: { + codec: codecForTestPayArgs(), + handler: testPay, + }, + [WalletApiOperation.GetTransactions]: { + codec: codecForTransactionsRequest(), + handler: getTransactions, + }, + [WalletApiOperation.GetTransactionById]: { + codec: codecForTransactionByIdRequest(), + handler: getTransactionById, + }, +}; + /** * Implementation of the "wallet-core" API. */ @@ -707,105 +1466,40 @@ async function dispatchRequestInternal( // definitions we already have? switch (operation) { case WalletApiOperation.CreateStoredBackup: - return createStoredBackup(wex); + return await handleCreateStoredBackup(wex, {}); case WalletApiOperation.DeleteStoredBackup: { const req = codecForDeleteStoredBackupRequest().decode(payload); - await deleteStoredBackup(wex, req); - return {}; + return await handleDeleteStoredBackup(wex, req); } case WalletApiOperation.ListStoredBackups: return listStoredBackups(wex); case WalletApiOperation.RecoverStoredBackup: { const req = codecForRecoverStoredBackupRequest().decode(payload); - await recoverStoredBackup(wex, req); - return {}; + return await handleRecoverStoredBackup(wex, req); } case WalletApiOperation.SetWalletRunConfig: case WalletApiOperation.InitWallet: { const req = codecForInitRequest().decode(payload); - - if (logger.shouldLogTrace()) { - const initType = wex.ws.initCalled - ? "repeat initialization" - : "first initialization"; - logger.trace(`init request (${initType}): ${j2s(req)}`); - } - - // Write to the DB to make sure that we're failing early in - // case the DB is not writeable. - try { - await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { - tx.config.put({ - key: ConfigRecordKey.LastInitInfo, - value: timestampProtocolToDb(TalerProtocolTimestamp.now()), - }); - }); - } catch (e) { - logger.error("error writing to database during initialization"); - throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, { - innerError: getErrorDetailFromException(e), - }); - } - wex.ws.initWithConfig(applyRunConfigDefaults(req.config)); - - if (wex.ws.config.testing.skipDefaults) { - logger.trace("skipping defaults"); - } else { - logger.trace("filling defaults"); - await fillDefaults(wex); - } - const resp: InitResponse = { - versionInfo: getVersion(wex), - }; - - if (req.config?.lazyTaskLoop) { - logger.trace("lazily starting task loop"); - } else { - await wex.taskScheduler.ensureRunning(); - } - - wex.ws.initCalled = true; - return resp; + return await handleSetWalletRunConfig(wex, req); } case WalletApiOperation.WithdrawTestkudos: { - await withdrawTestBalance(wex, { - amount: "TESTKUDOS:10" as AmountString, - corebankApiBaseUrl: "https://bank.test.taler.net/", - exchangeBaseUrl: "https://exchange.test.taler.net/", - }); - return { - versionInfo: getVersion(wex), - }; + return await handleWithdrawTestkudos(wex); } case WalletApiOperation.WithdrawTestBalance: { const req = codecForWithdrawTestBalance().decode(payload); - await withdrawTestBalance(wex, req); - return {}; - } - case WalletApiOperation.TestingListTaskForTransaction: { - const req = - codecForTestingListTasksForTransactionRequest().decode(payload); - return { - taskIdList: listTaskForTransactionId(req.transactionId), - } satisfies TestingListTasksForTransactionsResponse; + return await handleWithdrawTestBalance(wex, req); } case WalletApiOperation.RunIntegrationTest: { const req = codecForIntegrationTestArgs().decode(payload); - await runIntegrationTest(wex, req); - return {}; + return await handleRunIntegrationTest(wex, req); } case WalletApiOperation.RunIntegrationTestV2: { const req = codecForIntegrationTestV2Args().decode(payload); - await runIntegrationTest2(wex, req); - return {}; + return await handleRunIntegrationTestV2(wex, req); } case WalletApiOperation.ValidateIban: { const req = codecForValidateIbanRequest().decode(payload); - const valRes = validateIban(req.iban); - const resp: ValidateIbanResponse = { - valid: valRes.type === "valid", - }; - return resp; + return handleValidateIban(wex, req); } case WalletApiOperation.TestPay: { const req = codecForTestPayArgs().decode(payload); @@ -825,45 +1519,18 @@ async function dispatchRequestInternal( } case WalletApiOperation.AddExchange: { const req = codecForAddExchangeRequest().decode(payload); - await fetchFreshExchange(wex, req.exchangeBaseUrl, {}); - return {}; + return await handleAddExchange(wex, req); } case WalletApiOperation.TestingPing: { return {}; } case WalletApiOperation.UpdateExchangeEntry: { const req = codecForUpdateExchangeEntryRequest().decode(payload); - await fetchFreshExchange(wex, req.exchangeBaseUrl, { - forceUpdate: !!req.force, - }); - return {}; + return await handleUpdateExchangeEntry(wex, req); } case WalletApiOperation.TestingGetDenomStats: { const req = codecForTestingGetDenomStatsRequest().decode(payload); - const denomStats: TestingGetDenomStatsResponse = { - numKnown: 0, - numLost: 0, - numOffered: 0, - }; - await wex.db.runReadOnlyTx( - { storeNames: ["denominations"] }, - async (tx) => { - const denoms = - await tx.denominations.indexes.byExchangeBaseUrl.getAll( - req.exchangeBaseUrl, - ); - for (const d of denoms) { - denomStats.numKnown++; - if (d.isOffered) { - denomStats.numOffered++; - } - if (d.isLost) { - denomStats.numLost++; - } - } - }, - ); - return denomStats; + return handleTestingGetDenomStats(wex, req); } case WalletApiOperation.ListExchanges: { return await listExchanges(wex); @@ -875,20 +1542,7 @@ async function dispatchRequestInternal( case WalletApiOperation.ListExchangesForScopedCurrency: { const req = codecForListExchangesForScopedCurrencyRequest().decode(payload); - const exchangesResp = await listExchanges(wex); - const result: ExchangesShortListResponse = { - exchanges: [], - }; - // Right now we only filter on the currency, as wallet-core doesn't - // fully support scoped currencies yet. - for (const exch of exchangesResp.exchanges) { - if (exch.currency === req.scope.currency) { - result.exchanges.push({ - exchangeBaseUrl: exch.exchangeBaseUrl, - }); - } - } - return result; + return await handleListExchangesForScopedCurrency(wex, req); } case WalletApiOperation.GetExchangeDetailedInfo: { const req = codecForAddExchangeRequest().decode(payload); @@ -900,13 +1554,11 @@ async function dispatchRequestInternal( } case WalletApiOperation.AddKnownBankAccounts: { const req = codecForAddKnownBankAccounts().decode(payload); - await addKnownBankAccounts(wex, req.payto, req.alias, req.currency); - return {}; + return await handleAddKnownBankAccount(wex, req); } case WalletApiOperation.ForgetKnownBankAccounts: { const req = codecForForgetKnownBankAccounts().decode(payload); - await forgetKnownBankAccounts(wex, req.payto); - return {}; + return await handleForgetKnownBankAccounts(wex, req); } case WalletApiOperation.GetWithdrawalDetailsForUri: { const req = codecForGetWithdrawalDetailsForUri().decode(payload); @@ -914,48 +1566,16 @@ async function dispatchRequestInternal( } case WalletApiOperation.TestingGetReserveHistory: { const req = codecForTestingGetReserveHistoryRequest().decode(payload); - const reserve = await wex.db.runReadOnlyTx( - { storeNames: ["reserves"] }, - async (tx) => { - return tx.reserves.indexes.byReservePub.get(req.reservePub); - }, - ); - if (!reserve) { - throw Error("no reserve pub found"); - } - const sigResp = await wex.cryptoApi.signReserveHistoryReq({ - reservePriv: reserve.reservePriv, - startOffset: 0, - }); - const exchangeBaseUrl = req.exchangeBaseUrl; - const url = new URL( - `reserves/${req.reservePub}/history`, - exchangeBaseUrl, - ); - const resp = await wex.http.fetch(url.href, { - headers: { ["Taler-Reserve-History-Signature"]: sigResp.sig }, - }); - const historyJson = await readSuccessResponseJsonOrThrow( - resp, - codecForAny(), - ); - return historyJson; + return await handleTestingGetReserveHistory(wex, req); } case WalletApiOperation.AcceptManualWithdrawal: { const req = codecForAcceptManualWithdrawalRequest().decode(payload); - const res = await createManualWithdrawal(wex, { - amount: Amounts.parseOrThrow(req.amount), - exchangeBaseUrl: req.exchangeBaseUrl, - restrictAge: req.restrictAge, - forceReservePriv: req.forceReservePriv, - }); - return res; + return await handleAcceptManualWithdrawal(wex, req); } case WalletApiOperation.GetWithdrawalDetailsForAmount: { const req = codecForGetWithdrawalDetailsForAmountRequest().decode(payload); - const resp = await getWithdrawalDetailsForAmount(wex, cts, req); - return resp; + return await getWithdrawalDetailsForAmount(wex, cts, req); } case WalletApiOperation.GetBalances: { return await getBalances(wex); @@ -976,12 +1596,6 @@ async function dispatchRequestInternal( const req = codecForUserAttentionsRequest().decode(payload); return await getUserAttentionsUnreadCount(wex, req); } - case WalletApiOperation.GetPendingOperations: { - // FIXME: Eventually remove the handler after deprecation period. - return { - pendingOperations: [], - } satisfies PendingOperationsResponse; - } case WalletApiOperation.SetExchangeTosAccepted: { const req = codecForAcceptExchangeTosRequest().decode(payload); await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl); @@ -995,13 +1609,7 @@ async function dispatchRequestInternal( case WalletApiOperation.AcceptBankIntegratedWithdrawal: { const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); - return await acceptWithdrawalFromUri(wex, { - selectedExchange: req.exchangeBaseUrl, - talerWithdrawUri: req.talerWithdrawUri, - forcedDenomSel: req.forcedDenomSel, - restrictAge: req.restrictAge, - amount: req.amount, - }); + return handleAcceptBankIntegratedWithdrawal(wex, req); } case WalletApiOperation.ConfirmWithdrawal: { const req = codecForConfirmWithdrawalRequestRequest().decode(payload); @@ -1014,39 +1622,22 @@ async function dispatchRequestInternal( } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); - return getExchangeTos( - wex, - req.exchangeBaseUrl, - req.acceptedFormat, - req.acceptLanguage, - ); + return await handleGetExchangeTos(wex, req); } case WalletApiOperation.GetContractTermsDetails: { const req = codecForGetContractTermsDetails().decode(payload); - if (req.proposalId) { - // FIXME: deprecated path - return getContractTermsDetails(wex, req.proposalId); - } - if (req.transactionId) { - const parsedTx = parseTransactionIdentifier(req.transactionId); - if (parsedTx?.tag === TransactionType.Payment) { - return getContractTermsDetails(wex, parsedTx.proposalId); - } - throw Error("transactionId is not a payment transaction"); - } - throw Error("transactionId missing"); + return handleGetContractTermsDetails(wex, req); } case WalletApiOperation.RetryPendingNow: { - logger.error("retryPendingNow currently not implemented"); - return {}; + return handleRetryPendingNow(wex); } case WalletApiOperation.SharePayment: { const req = codecForSharePaymentRequest().decode(payload); - return await sharePayment(wex, req.merchantBaseUrl, req.orderId); + return await handleSharePayment(wex, req); } case WalletApiOperation.PrepareWithdrawExchange: { const req = codecForPrepareWithdrawExchangeRequest().decode(payload); - return handlePrepareWithdrawExchange(wex, req); + return await handlePrepareWithdrawExchange(wex, req); } case WalletApiOperation.CheckPayForTemplate: { const req = codecForCheckPayTemplateRequest().decode(payload); @@ -1060,74 +1651,28 @@ async function dispatchRequestInternal( const req = codecForPreparePayTemplateRequest().decode(payload); return preparePayForTemplate(wex, req); } + case WalletApiOperation.GetQrCodesForPayto: { + const req = codecForGetQrCodesForPaytoRequest().decode(payload); + return handleGetQrCodesForPayto(wex, req); + } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); - let transactionId; - if (req.proposalId) { - // legacy client support - transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: req.proposalId, - }); - } else if (req.transactionId) { - transactionId = req.transactionId; - } else { - throw Error("transactionId or (deprecated) proposalId required"); - } - return await confirmPay(wex, transactionId, req.sessionId); + return handleConfirmPay(wex, req); } case WalletApiOperation.AbortTransaction: { const req = codecForAbortTransaction().decode(payload); - await abortTransaction(wex, req.transactionId); - return {}; + return handleAbortTransaction(wex, req); } case WalletApiOperation.SuspendTransaction: { const req = codecForSuspendTransaction().decode(payload); - await suspendTransaction(wex, req.transactionId); - return {}; + return handleSuspendTransaction(wex, req); } case WalletApiOperation.GetActiveTasks: { - const allTasksId = (await getActiveTaskIds(wex.ws)).taskIds; - - const tasksInfo = await Promise.all( - allTasksId.map(async (id) => { - return await wex.db.runReadOnlyTx( - { storeNames: ["operationRetries"] }, - async (tx) => { - return tx.operationRetries.get(id); - }, - ); - }), - ); - - const tasks = allTasksId.map((taskId, i): ActiveTask => { - const transaction = convertTaskToTransactionId(taskId); - const d = tasksInfo[i]; - - const firstTry = !d - ? undefined - : timestampAbsoluteFromDb(d.retryInfo.firstTry); - const nextTry = !d - ? undefined - : timestampAbsoluteFromDb(d.retryInfo.nextRetry); - const counter = d?.retryInfo.retryCounter; - const lastError = d?.lastError; - - return { - taskId: taskId, - retryCounter: counter, - firstTry, - nextTry, - lastError, - transaction, - }; - }); - return { tasks }; + return await handleGetActiveTasks(wex, {}); } case WalletApiOperation.FailTransaction: { const req = codecForFailTransactionRequest().decode(payload); - await failTransaction(wex, req.transactionId); - return {}; + return await handleFailTransaction(wex, req); } case WalletApiOperation.ResumeTransaction: { const req = codecForResumeTransaction().decode(payload); @@ -1143,7 +1688,8 @@ async function dispatchRequestInternal( return {}; } case WalletApiOperation.TestingGetSampleTransactions: - return { transactions: sampleWalletCoreTransactions }; + const req = codecForEmptyObject().decode(payload); + return handleTestingGetSampleTransactions(wex, req); case WalletApiOperation.ForceRefresh: { const req = codecForForceRefreshRequest().decode(payload); return await forceRefresh(wex, req); @@ -1154,15 +1700,7 @@ async function dispatchRequestInternal( } case WalletApiOperation.StartRefundQuery: { const req = codecForStartRefundQueryRequest().decode(payload); - const txIdParsed = parseTransactionIdentifier(req.transactionId); - if (!txIdParsed) { - throw Error("invalid transaction ID"); - } - if (txIdParsed.tag !== TransactionType.Payment) { - throw Error("expected payment transaction ID"); - } - await startQueryRefund(wex, txIdParsed.proposalId); - return {}; + return handleStartRefundQuery(wex, req); } case WalletApiOperation.AddBackupProvider: { const req = codecForAddBackupProviderRequest().decode(payload); @@ -1170,8 +1708,7 @@ async function dispatchRequestInternal( } case WalletApiOperation.RunBackupCycle: { const req = codecForRunBackupCycle().decode(payload); - await runBackupCycle(wex, req); - return {}; + return handleAddBackupProvider(wex, req); } case WalletApiOperation.RemoveBackupProvider: { const req = codecForRemoveBackupProvider().decode(payload); @@ -1188,48 +1725,8 @@ async function dispatchRequestInternal( return {}; } case WalletApiOperation.GetCurrencySpecification: { - // Ignore result, just validate in this mock implementation const req = codecForGetCurrencyInfoRequest().decode(payload); - // Hard-coded mock for KUDOS and TESTKUDOS - if (req.scope.currency === "KUDOS") { - const kudosResp: GetCurrencySpecificationResponse = { - currencySpecification: { - name: "Kudos (Taler Demonstrator)", - num_fractional_input_digits: 2, - num_fractional_normal_digits: 2, - num_fractional_trailing_zero_digits: 2, - alt_unit_names: { - "0": "ク", - }, - }, - }; - return kudosResp; - } else if (req.scope.currency === "TESTKUDOS") { - const testkudosResp: GetCurrencySpecificationResponse = { - currencySpecification: { - name: "Test (Taler Unstable Demonstrator)", - num_fractional_input_digits: 0, - num_fractional_normal_digits: 0, - num_fractional_trailing_zero_digits: 0, - alt_unit_names: { - "0": "テ", - }, - }, - }; - return testkudosResp; - } - const defaultResp: GetCurrencySpecificationResponse = { - currencySpecification: { - name: req.scope.currency, - num_fractional_input_digits: 2, - num_fractional_normal_digits: 2, - num_fractional_trailing_zero_digits: 2, - alt_unit_names: { - "0": req.scope.currency, - }, - }, - }; - return defaultResp; + return handleGetCurrencySpecification(wex, req); } case WalletApiOperation.ImportBackupRecovery: { const req = codecForAny().decode(payload); @@ -1238,13 +1735,7 @@ async function dispatchRequestInternal( } case WalletApiOperation.HintNetworkAvailability: { const req = codecForHintNetworkAvailabilityRequest().decode(payload); - if (req.isNetworkAvailable) { - await retryAll(wex); - } else { - // We're not doing anything right now, but we could stop showing - // certain errors! - } - return {}; + return await handleHintNetworkAvailability(wex, req); } case WalletApiOperation.ConvertDepositAmount: { const req = codecForConvertAmountRequest.decode(payload); @@ -1313,117 +1804,30 @@ async function dispatchRequestInternal( const dbDump = await exportDb(wex.ws.idb); return dbDump; } + case WalletApiOperation.GetDepositWireTypesForCurrency: { + const req = + codecForGetDepositWireTypesForCurrencyRequest().decode(payload); + return handleGetDepositWireTypesForCurrency(wex, req); + } case WalletApiOperation.ListGlobalCurrencyExchanges: { - const resp: ListGlobalCurrencyExchangesResponse = { - exchanges: [], - }; - await wex.db.runReadOnlyTx( - { storeNames: ["globalCurrencyExchanges"] }, - async (tx) => { - const gceList = await tx.globalCurrencyExchanges.iter().toArray(); - for (const gce of gceList) { - resp.exchanges.push({ - currency: gce.currency, - exchangeBaseUrl: gce.exchangeBaseUrl, - exchangeMasterPub: gce.exchangeMasterPub, - }); - } - }, - ); - return resp; + const req = codecForEmptyObject().decode(payload); + return await handleListGlobalCurrencyExchanges(wex, req); } case WalletApiOperation.ListGlobalCurrencyAuditors: { - const resp: ListGlobalCurrencyAuditorsResponse = { - auditors: [], - }; - await wex.db.runReadOnlyTx( - { storeNames: ["globalCurrencyAuditors"] }, - async (tx) => { - const gcaList = await tx.globalCurrencyAuditors.iter().toArray(); - for (const gca of gcaList) { - resp.auditors.push({ - currency: gca.currency, - auditorBaseUrl: gca.auditorBaseUrl, - auditorPub: gca.auditorPub, - }); - } - }, - ); - return resp; + const req = codecForEmptyObject().decode(payload); + return await handleListGlobalCurrencyAuditors(wex, req); } case WalletApiOperation.AddGlobalCurrencyExchange: { const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload); - await wex.db.runReadWriteTx( - { storeNames: ["globalCurrencyExchanges"] }, - async (tx) => { - const key = [ - req.currency, - req.exchangeBaseUrl, - req.exchangeMasterPub, - ]; - const existingRec = - await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( - key, - ); - if (existingRec) { - return; - } - wex.ws.exchangeCache.clear(); - await tx.globalCurrencyExchanges.add({ - currency: req.currency, - exchangeBaseUrl: req.exchangeBaseUrl, - exchangeMasterPub: req.exchangeMasterPub, - }); - }, - ); - return {}; + return handleAddGlobalCurrencyExchange(wex, req); } case WalletApiOperation.RemoveGlobalCurrencyExchange: { const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload); - await wex.db.runReadWriteTx( - { storeNames: ["globalCurrencyExchanges"] }, - async (tx) => { - const key = [ - req.currency, - req.exchangeBaseUrl, - req.exchangeMasterPub, - ]; - const existingRec = - await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( - key, - ); - if (!existingRec) { - return; - } - wex.ws.exchangeCache.clear(); - checkDbInvariant(!!existingRec.id, `no global exchange for ${j2s(key)}`); - await tx.globalCurrencyExchanges.delete(existingRec.id); - }, - ); - return {}; + return handleRemoveGlobalCurrencyExchange(wex, req); } case WalletApiOperation.AddGlobalCurrencyAuditor: { const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload); - await wex.db.runReadWriteTx( - { storeNames: ["globalCurrencyAuditors"] }, - async (tx) => { - const key = [req.currency, req.auditorBaseUrl, req.auditorPub]; - const existingRec = - await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( - key, - ); - if (existingRec) { - return; - } - await tx.globalCurrencyAuditors.add({ - currency: req.currency, - auditorBaseUrl: req.auditorBaseUrl, - auditorPub: req.auditorPub, - }); - wex.ws.exchangeCache.clear(); - }, - ); - return {}; + return handleAddGlobalCurrencyAuditor(wex, req); } case WalletApiOperation.TestingWaitTasksDone: { await waitTasksDone(wex); @@ -1434,23 +1838,7 @@ async function dispatchRequestInternal( return {}; case WalletApiOperation.RemoveGlobalCurrencyAuditor: { const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload); - await wex.db.runReadWriteTx( - { storeNames: ["globalCurrencyAuditors"] }, - async (tx) => { - const key = [req.currency, req.auditorBaseUrl, req.auditorPub]; - const existingRec = - await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( - key, - ); - if (!existingRec) { - return; - } - checkDbInvariant(!!existingRec.id, `no global currency for ${j2s(key)}`); - await tx.globalCurrencyAuditors.delete(existingRec.id); - wex.ws.exchangeCache.clear(); - }, - ); - return {}; + return await handleRemoveGlobalCurrencyAuditor(wex, req); } case WalletApiOperation.ImportDb: { const req = codecForImportDbRequest().decode(payload); @@ -1495,11 +1883,11 @@ async function dispatchRequestInternal( return {}; } case WalletApiOperation.Shutdown: { - wex.ws.stop(); - return {}; + const req = codecForEmptyObject().decode(payload); + return await handleShutdown(wex, req); } case WalletApiOperation.GetVersion: { - return getVersion(wex); + return handleGetVersion(wex); } case WalletApiOperation.TestingWaitTransactionsFinal: return await waitUntilAllTransactionsFinal(wex); @@ -1507,14 +1895,11 @@ async function dispatchRequestInternal( return await waitUntilRefreshesDone(wex); case WalletApiOperation.TestingSetTimetravel: { const req = codecForTestingSetTimetravelRequest().decode(payload); - setDangerousTimetravel(req.offsetMs); - await wex.taskScheduler.reload(); - return {}; + return await handleTestingSetTimetravel(wex, req); } case WalletApiOperation.DeleteExchange: { const req = codecForDeleteExchangeRequest().decode(payload); - await deleteExchange(wex, req); - return {}; + return await handleDeleteExchange(wex, req); } case WalletApiOperation.GetExchangeResources: { const req = codecForGetExchangeResourcesRequest().decode(payload); @@ -1522,45 +1907,8 @@ async function dispatchRequestInternal( } case WalletApiOperation.CanonicalizeBaseUrl: { const req = codecForCanonicalizeBaseUrlRequest().decode(payload); - return { - url: canonicalizeBaseUrl(req.url), - }; - } - case WalletApiOperation.TestingInfiniteTransactionLoop: { - const myDelayMs = (payload as any).delayMs ?? 5; - const shouldFetch = !!(payload as any).shouldFetch; - const doFetch = async () => { - while (1) { - const url = - "https://exchange.demo.taler.net/reserves/01PMMB9PJN0QBWAFBXV6R0KNJJMAKXCV4D6FDG0GJFDJQXGYP32G?timeout_ms=30000"; - logger.info(`fetching ${url}`); - const res = await wex.http.fetch(url); - logger.info(`fetch result ${res.status}`); - } - }; - if (shouldFetch) { - // In the background! - doFetch(); - } - let loopCount = 0; - while (true) { - logger.info(`looping test write tx, iteration ${loopCount}`); - await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { - await tx.config.put({ - key: ConfigRecordKey.TestLoopTx, - value: loopCount, - }); - }); - if (myDelayMs != 0) { - await new Promise<void>((resolve, reject) => { - setTimeout(() => resolve(), myDelayMs); - }); - } - loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1); - } + return handleCanonicalizeBaseUrl(wex, req); } - // default: - // assertUnreachable(operation); } throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, @@ -1571,7 +1919,66 @@ async function dispatchRequestInternal( ); } -export function getVersion(wex: WalletExecutionContext): WalletCoreVersion { +export async function handleGetCurrencySpecification( + wex: WalletExecutionContext, + req: GetCurrencySpecificationRequest, +): Promise<GetCurrencySpecificationResponse> { + const spec = await wex.db.runReadOnlyTx( + { + storeNames: ["currencyInfo"], + }, + async (tx) => { + return WalletDbHelpers.getCurrencyInfo(tx, req.scope); + }, + ); + if (spec) { + return { + currencySpecification: spec.currencySpec, + }; + } + // Hard-coded mock for KUDOS and TESTKUDOS + if (req.scope.currency === "KUDOS") { + const kudosResp: GetCurrencySpecificationResponse = { + currencySpecification: { + name: "Kudos (Taler Demonstrator)", + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2, + alt_unit_names: { + "0": "ク", + }, + }, + }; + return kudosResp; + } else if (req.scope.currency === "TESTKUDOS") { + const testkudosResp: GetCurrencySpecificationResponse = { + currencySpecification: { + name: "Test (Taler Unstable Demonstrator)", + num_fractional_input_digits: 0, + num_fractional_normal_digits: 0, + num_fractional_trailing_zero_digits: 0, + alt_unit_names: { + "0": "テ", + }, + }, + }; + return testkudosResp; + } + const defaultResp: GetCurrencySpecificationResponse = { + currencySpecification: { + name: req.scope.currency, + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2, + alt_unit_names: { + "0": req.scope.currency, + }, + }, + }; + return defaultResp; +} + +function handleGetVersion(wex: WalletExecutionContext): WalletCoreVersion { const result: WalletCoreVersion = { implementationSemver: walletCoreBuildInfo.implementationSemver, implementationGitHash: walletCoreBuildInfo.implementationGitHash, @@ -1580,9 +1987,9 @@ export function getVersion(wex: WalletExecutionContext): WalletCoreVersion { exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, - bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + bankIntegrationApiRange: TalerBankIntegrationHttpClient.PROTOCOL_VERSION, corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION, - bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + bank: TalerBankIntegrationHttpClient.PROTOCOL_VERSION, devMode: wex.ws.config.testing.devModeActive, }; return result; @@ -1593,11 +2000,12 @@ export function getObservedWalletExecutionContext( cancellationToken: CancellationToken, oc: ObservabilityContext, ): WalletExecutionContext { + const db = ws.createDbAccessHandle(cancellationToken); const wex: WalletExecutionContext = { ws, cancellationToken, cryptoApi: observeTalerCrypto(ws.cryptoApi, oc), - db: new ObservableDbAccess(ws.db, oc), + db: new ObservableDbAccess(db, oc), http: new ObservableHttpClientLibrary(ws.http, oc), taskScheduler: new ObservableTaskScheduler(ws.taskScheduler, oc), oc, @@ -1610,11 +2018,12 @@ export function getNormalWalletExecutionContext( cancellationToken: CancellationToken, oc: ObservabilityContext, ): WalletExecutionContext { + const db = ws.createDbAccessHandle(cancellationToken); const wex: WalletExecutionContext = { ws, cancellationToken, cryptoApi: ws.cryptoApi, - db: ws.db, + db, get http() { if (ws.initCalled) { return ws.http; @@ -1861,6 +2270,16 @@ class WalletDbTriggerSpec implements TriggerSpec { } } +type LongpollRunFn<T> = (timeoutMs: number) => Promise<T>; +type ResolveFn = () => void; + +/** + * Per-hostname state for longpolling. + */ +interface LongpollState { + queue: Array<ResolveFn>; +} + /** * Internal state of the wallet. * @@ -1868,10 +2287,9 @@ class WalletDbTriggerSpec implements TriggerSpec { */ export class InternalWalletState { cryptoApi: TalerCryptoInterface; - cryptoDispatcher: CryptoDispatcher; + private cryptoDispatcher: CryptoDispatcher; readonly timerGroup: TimerGroup; - workAvailable = new AsyncCondition(); stopped = false; private listeners: NotificationListener[] = []; @@ -1913,6 +2331,14 @@ export class InternalWalletState { private _http: HttpRequestLibrary | undefined = undefined; + devExperimentState: DevExperimentState = {}; + + clientCancellationMap: Map<string, CancellationToken.Source> = new Map(); + + private longpollStatePerHostname: Map<string, LongpollState> = new Map(); + + private longpollRequestIdCounter = 1; + get db(): DbAccess<typeof WalletStoresV1> { if (!this._dbAccessHandle) { this._dbAccessHandle = this.createDbAccessHandle( @@ -1922,9 +2348,83 @@ export class InternalWalletState { return this._dbAccessHandle; } - devExperimentState: DevExperimentState = {}; + /** + * When set to false, all tasks that require network will be stopped and + * retried until connection is restored. + * + * Set to true by default for compatibility with clients that don't hint + * network availability via hintNetworkAvailability. + */ + private _networkAvailable = true; - clientCancellationMap: Map<string, CancellationToken.Source> = new Map(); + get networkAvailable(): boolean { + return this._networkAvailable; + } + + set networkAvailable(status: boolean) { + this._networkAvailable = status; + } + + /** + * Run a long-polling request, potentially queueing the request + * if too many other long-polling requests against the same hostname + * (or too many overall) are active. + */ + async runLongpollQueueing<T>( + wex: WalletExecutionContext, + hostname: string, + f: LongpollRunFn<T>, + ): Promise<T> { + let rid = this.longpollRequestIdCounter++; + const triggerNextLongpoll = () => { + logger.info(`cleaning up after long-poll ${rid} request to ${hostname}`); + const st = this.longpollStatePerHostname.get(hostname); + if (!st) { + return; + } + const next = st.queue.shift(); + if (next) { + next(); + } else { + this.longpollStatePerHostname.delete(hostname); + } + }; + const doRunLongpoll: () => Promise<T> = async () => { + const st = this.longpollStatePerHostname.get(hostname); + const numWaiting = st?.queue.length ?? 0; + logger.info( + `running long-poll ${rid} to ${hostname} with ${numWaiting} waiting`, + ); + try { + const timeoutMs = Math.round(Math.max(10000, 30000 / (numWaiting + 1))); + return await f(timeoutMs); + } finally { + triggerNextLongpoll(); + } + }; + const state = this.longpollStatePerHostname.get(hostname); + if (state) { + logger.info(`long-poll request ${rid} to ${hostname} queued`); + const promcap = openPromise<void>(); + state.queue.push(promcap.resolve); + try { + await wex.cancellationToken.racePromise(promcap.promise); + } catch (e) { + logger.info( + `long-poll request ${rid} to ${hostname} cancelled while queued`, + ); + triggerNextLongpoll(); + throw e; + } + return doRunLongpoll(); + } else { + logger.info(`directly running long-poll request ${rid} to ${hostname}`); + this.longpollStatePerHostname.set(hostname, { + queue: [], + }); + return Promise.resolve().then(doRunLongpoll); + } + } clearAllCaches(): void { this.exchangeCache.clear(); diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts index 0434aefc2..3e2f4411f 100644 --- a/packages/taler-wallet-core/src/withdraw.ts +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -64,6 +64,7 @@ import { TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, + TalerUriAction, Transaction, TransactionAction, TransactionIdStr, @@ -95,6 +96,7 @@ import { getRandomBytes, j2s, makeErrorDetail, + parseTalerUri, parseWithdrawUri, } from "@gnu-taler/taler-util"; import { @@ -118,6 +120,7 @@ import { genericWaitForState, makeCoinAvailable, makeCoinsVisible, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; import { @@ -162,10 +165,7 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - WALLET_EXCHANGE_PROTOCOL_VERSION, -} from "./versions.js"; +import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; /** @@ -826,12 +826,19 @@ async function processWithdrawalGroupDialogProposed( ); url.searchParams.set("old_state", "pending"); - url.searchParams.set("long_poll_ms", "30000"); - const resp = await ctx.wex.http.fetch(url.href, { - method: "GET", - cancellationToken: ctx.wex.cancellationToken, - }); + const resp = await ctx.wex.ws.runLongpollQueueing( + ctx.wex, + url.hostname, + async (timeoutMs) => { + url.searchParams.set("long_poll_ms", `${timeoutMs}`); + + return await ctx.wex.http.fetch(url.href, { + method: "GET", + cancellationToken: ctx.wex.cancellationToken, + }); + }, + ); // If the bank claims that the withdrawal operation is already // pending, but we're still in DialogProposed, some other wallet @@ -1100,6 +1107,10 @@ enum ExchangeAmlStatus { Frozen = 2, } +/** + * Transition a transaction from pending(ready) + * into a pending(kyc|aml) state, in case KYC is required. + */ async function handleKycRequired( wex: WalletExecutionContext, withdrawalGroup: WithdrawalGroupRecord, @@ -1124,6 +1135,7 @@ async function handleKycRequired( exchangeUrl, ); logger.info(`kyc url ${url.href}`); + // We do not longpoll here, as this is the initial request to get information about the KYC. const kycStatusRes = await wex.http.fetch(url.href, { method: "GET", cancellationToken: wex.cancellationToken, @@ -1463,7 +1475,6 @@ async function processPlanchetVerifyAndStoreCoin( sourceTransactionId: transactionId, maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED, ageCommitmentProof: planchet.ageCommitmentProof, - spendAllocation: undefined, }; const planchetCoinPub = planchet.coinPub; @@ -1616,14 +1627,19 @@ async function processQueryReserve( `reserves/${reservePub}`, withdrawalGroup.exchangeBaseUrl, ); - reserveUrl.searchParams.set("timeout_ms", "30000"); - logger.trace(`querying reserve status via ${reserveUrl.href}`); - - const resp = await wex.http.fetch(reserveUrl.href, { - timeout: getReserveRequestTimeout(withdrawalGroup), - cancellationToken: wex.cancellationToken, - }); + const resp = await wex.ws.runLongpollQueueing( + wex, + reserveUrl.hostname, + async (timeoutMs) => { + reserveUrl.searchParams.set("timeout_ms", `${timeoutMs}`); + logger.trace(`querying reserve status via ${reserveUrl.href}`); + return await wex.http.fetch(reserveUrl.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + }, + ); logger.trace(`reserve status code: HTTP ${resp.status}`); @@ -1762,12 +1778,20 @@ async function processWithdrawalGroupPendingKyc( `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, exchangeUrl, ); - url.searchParams.set("timeout_ms", "30000"); - logger.info(`long-polling for withdrawal KYC status via ${url.href}`); - const kycStatusRes = await wex.http.fetch(url.href, { - method: "GET", - cancellationToken: wex.cancellationToken, - }); + + const kycStatusRes = await wex.ws.runLongpollQueueing( + wex, + url.hostname, + async (timeoutMs) => { + url.searchParams.set("timeout_ms", `${timeoutMs}`); + logger.info(`long-polling for withdrawal KYC status via ${url.href}`); + return await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + }, + ); + logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`); if ( kycStatusRes.status === HttpStatusCode.Ok || @@ -2158,6 +2182,10 @@ export async function processWithdrawalGroup( wex: WalletExecutionContext, withdrawalGroupId: string, ): Promise<TaskRunResult> { + if (!wex.ws.networkAvailable) { + return TaskRunResult.networkRequired(); + } + logger.trace("processing withdrawal group", withdrawalGroupId); const withdrawalGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, @@ -2268,14 +2296,6 @@ export async function getExchangeWithdrawalInfo( logger.trace("selection done"); - if (selectedDenoms.selectedDenoms.length === 0) { - throw Error( - `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify( - instructedAmount, - )}`, - ); - } - const exchangeWireAccounts: string[] = []; for (const account of exchange.wireInfo.accounts) { @@ -2340,20 +2360,10 @@ export interface GetWithdrawalDetailsForUriOpts { notifyChangeFromPendingTimeoutMs?: number; } -/** - * Get more information about a taler://withdraw URI. - * - * As side effects, the bank (via the bank integration API) is queried - * and the exchange suggested by the bank is ephemerally added - * to the wallet's list of known exchanges. - */ -export async function getWithdrawalDetailsForUri( +async function getWithdrawalDetailsForBankInfo( wex: WalletExecutionContext, - talerWithdrawUri: string, + info: BankWithdrawDetails, ): Promise<WithdrawUriInfoResponse> { - logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); - const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri); - logger.trace(`got bank info`); if (info.exchange) { try { // If the exchange entry doesn't exist yet, @@ -2403,6 +2413,23 @@ export async function getWithdrawalDetailsForUri( }; } +/** + * Get more information about a taler://withdraw URI. + * + * As side effects, the bank (via the bank integration API) is queried + * and the exchange suggested by the bank is ephemerally added + * to the wallet's list of known exchanges. + */ +export async function getWithdrawalDetailsForUri( + wex: WalletExecutionContext, + talerWithdrawUri: string, +): Promise<WithdrawUriInfoResponse> { + logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); + const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri); + logger.trace(`got bank info`); + return getWithdrawalDetailsForBankInfo(wex, info); +} + export function augmentPaytoUrisForWithdrawal( plainPaytoUris: string[], reservePub: string, @@ -3039,8 +3066,25 @@ export async function prepareBankIntegratedWithdrawal( }, ); + const parsedUri = parseTalerUri(req.talerWithdrawUri); + if (parsedUri?.type !== TalerUriAction.Withdraw) { + throw TalerError.fromDetail(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {}); + } + + const externalConfirmation = parsedUri.externalConfirmation; + + logger.info( + `creating withdrawal with externalConfirmation=${externalConfirmation}`, + ); + + const withdrawInfo = await getBankWithdrawalInfo( + wex.http, + req.talerWithdrawUri, + ); + + const info = await getWithdrawalDetailsForBankInfo(wex, withdrawInfo); + if (existingWithdrawalGroup) { - const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri); return { transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, @@ -3049,12 +3093,6 @@ export async function prepareBankIntegratedWithdrawal( info, }; } - const withdrawInfo = await getBankWithdrawalInfo( - wex.http, - req.talerWithdrawUri, - ); - - const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri); /** * Withdrawal group without exchange and amount @@ -3074,13 +3112,13 @@ export async function prepareBankIntegratedWithdrawal( timestampReserveInfoPosted: undefined, wireTypes: withdrawInfo.wireTypes, currency: withdrawInfo.currency, + externalConfirmation, }, }, reserveStatus: WithdrawalGroupStatus.DialogProposed, }); const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; - const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); wex.taskScheduler.startShepherdTask(ctx.taskId); @@ -3121,6 +3159,7 @@ export async function confirmWithdrawal( } const exchange = await fetchFreshExchange(wex, selectedExchange); + requireExchangeTosAcceptedOrThrow(exchange); const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; const confirmUrl = withdrawalGroup.wgInfo.bankInfo.confirmUrl; @@ -3195,22 +3234,14 @@ export async function confirmWithdrawal( rec.denomsSel = initalDenoms; rec.rawWithdrawalAmount = initalDenoms.totalWithdrawCost; rec.effectiveWithdrawalAmount = initalDenoms.totalCoinValue; - - rec.wgInfo = { - withdrawalType: WithdrawalRecordType.BankIntegrated, - exchangeCreditAccounts: withdrawalAccountList, - bankInfo: { - exchangePaytoUri, - talerWithdrawUri, - confirmUrl: confirmUrl, - timestampBankConfirmed: undefined, - timestampReserveInfoPosted: undefined, - wireTypes: bankWireTypes, - currency: bankCurrency, - }, - }; - pending = true; + checkDbInvariant( + rec.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated, + "withdrawal type mismatch", + ); + rec.wgInfo.exchangeCreditAccounts = withdrawalAccountList; + rec.wgInfo.bankInfo.exchangePaytoUri = exchangePaytoUri; rec.status = WithdrawalGroupStatus.PendingRegisteringBank; + pending = true; return TransitionResult.transition(rec); } default: { @@ -3287,14 +3318,21 @@ export async function acceptWithdrawalFromUri( "amount required, as withdrawal operation has flexible amount", ); } - amount = req.amount as AmountString; + amount = Amounts.stringify(req.amount); } else { - if (req.amount != null && Amounts.cmp(req.amount, p.info.amount) != 0) { - throw Error( - "mismatched amount, amount is fixed by bank but client provided different amount", - ); + if (req.amount == null) { + amount = p.info.amount; + } else { + if ( + Amounts.cmp(p.info.amount, req.amount) != 0 && + !p.info.editableAmount + ) { + throw Error( + `mismatched amount, amount is fixed by bank (${p.info.amount}) but client provided different amount (${req.amount})`, + ); + } + amount = Amounts.stringify(req.amount); } - amount = p.info.amount; } logger.info(`confirming withdrawal with tx ${p.transactionId}`); diff --git a/packages/taler-wallet-embedded/package.json b/packages/taler-wallet-embedded/package.json index fe64396fb..dbdb23673 100644 --- a/packages/taler-wallet-embedded/package.json +++ b/packages/taler-wallet-embedded/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-embedded", - "version": "0.11.4", + "version": "0.12.2", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-webextension/manifest-common.json b/packages/taler-wallet-webextension/manifest-common.json index 88f152d50..01248e964 100644 --- a/packages/taler-wallet-webextension/manifest-common.json +++ b/packages/taler-wallet-webextension/manifest-common.json @@ -2,7 +2,7 @@ "name": "GNU Taler Wallet (git)", "description": "Privacy preserving and transparent payments", "author": "GNU Taler Developers", - "version": "0.11.4", + "version": "0.12.2", "icons": { "16": "static/img/taler-logo-16.png", "19": "static/img/taler-logo-19.png", @@ -14,5 +14,5 @@ "256": "static/img/taler-logo-256.png", "512": "static/img/taler-logo-512.png" }, - "version_name": "0.11.4" + "version_name": "0.12.2" } diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index 90679cfdd..5c622da70 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-webextension", - "version": "0.11.4", + "version": "0.12.2", "description": "GNU Taler Wallet browser extension", "main": "./build/index.js", "types": "./build/index.d.ts", diff --git a/packages/taler-wallet-webextension/src/components/CurrentAlerts.tsx b/packages/taler-wallet-webextension/src/components/CurrentAlerts.tsx index b1ed3b02c..38d1b6b6b 100644 --- a/packages/taler-wallet-webextension/src/components/CurrentAlerts.tsx +++ b/packages/taler-wallet-webextension/src/components/CurrentAlerts.tsx @@ -22,6 +22,8 @@ import { } from "../context/alert.js"; import { Alert } from "../mui/Alert.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ButtonHandler } from "../mui/handlers.js"; +import { Button } from "../mui/Button.js"; /** * @@ -99,13 +101,23 @@ function AlertContext({ export function ErrorAlertView({ error, + retry, onClose, }: { error: AlertNotification; + retry?: ButtonHandler; onClose?: () => Promise<void>; }): VNode { + const { i18n } = useTranslationContext(); return ( <Wrapper> + {!retry ? undefined : ( + <section> + <Button variant="contained" color="success" onClick={retry.onClick}> + <i18n.Translate>Retry operation</i18n.Translate> + </Button> + </section> + )} <AlertView alert={error} onClose={onClose} /> </Wrapper> ); diff --git a/packages/taler-wallet-webextension/src/components/Modal.tsx b/packages/taler-wallet-webextension/src/components/Modal.tsx index f8c0f1651..c5f716c76 100644 --- a/packages/taler-wallet-webextension/src/components/Modal.tsx +++ b/packages/taler-wallet-webextension/src/components/Modal.tsx @@ -52,8 +52,7 @@ const Body = styled.div` export function Modal({ title, children, onClose }: Props): VNode { return ( - <div style={{ top: 0, width: "100%", height: "100%" }}> - + <div style={{ top: 0, position: "fixed", width: "100%", height: "100%" }}> <FullSize onClick={onClose?.onClick}> <div onClick={(e) => e.stopPropagation()} diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts index 1585e3992..7ef5b95a1 100644 --- a/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts +++ b/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts @@ -17,7 +17,11 @@ import { ComponentChildren } from "preact"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; -import { SelectFieldHandler, ToggleHandler } from "../../mui/handlers.js"; +import { + ButtonHandler, + SelectFieldHandler, + ToggleHandler, +} from "../../mui/handlers.js"; import { StateViewMap, compose } from "../../utils/index.js"; import { ErrorAlertView } from "../CurrentAlerts.js"; import { useComponentState } from "./state.js"; @@ -61,6 +65,7 @@ export namespace State { status: "show-content"; termsAccepted: ToggleHandler; showingTermsOfService?: ToggleHandler; + skipTos: ButtonHandler; tosLang: SelectFieldHandler; tosFormat: SelectFieldHandler; } @@ -68,7 +73,7 @@ export namespace State { status: "show-buttons-accepted"; termsAccepted: ToggleHandler; showingTermsOfService: ToggleHandler; - children: ComponentChildren, + children: ComponentChildren; } export interface ShowButtonsNotAccepted extends BaseInfo { status: "show-buttons-not-accepted"; diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts index 76524f0f4..96d14dadf 100644 --- a/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts +++ b/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts @@ -25,22 +25,28 @@ import { buildTermsOfServiceState } from "./utils.js"; const supportedFormats = { "text/html": "HTML", - "text/xml" : "XML", - "text/markdown" : "Markdown", - "text/plain" : "Plain text", - "text/pdf" : "PDF", -} - -export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, children }: Props): State { + "text/xml": "XML", + "text/markdown": "Markdown", + "text/plain": "Plain text", + "text/pdf": "PDF", +}; + +export function useComponentState({ + showEvenIfaccepted, + exchangeUrl, + readOnly, + children, +}: Props): State { const api = useBackendContext(); const [showContent, setShowContent] = useState<boolean>(!!readOnly); const { i18n, lang } = useTranslationContext(); - const [tosLang, setTosLang] = useState<string>() + const [forceAccepted, setForceAccepted] = useState<boolean>(); + const [tosLang, setTosLang] = useState<string>(); const { pushAlertOnError } = useAlertContext(); - const [format, setFormat] = useState("text/html") + const [format, setFormat] = useState("text/html"); - const acceptedLang = tosLang ?? lang + const acceptedLang = tosLang ?? lang; /** * For the exchange selected, bring the status of the terms of service */ @@ -54,10 +60,13 @@ export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, c }, ); - const supportedLangs = exchangeTos.tosAvailableLanguages.reduce((prev, cur) => { - prev[cur] = cur - return prev; - }, {} as Record<string, string>) + const supportedLangs = exchangeTos.tosAvailableLanguages.reduce( + (prev, cur) => { + prev[cur] = cur; + return prev; + }, + {} as Record<string, string>, + ); const state = buildTermsOfServiceState(exchangeTos); @@ -92,30 +101,35 @@ export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, c } else { // mark as not accepted } - terms?.retry() + terms?.retry(); } - const accepted = state.status === "accepted"; + const accepted = state.status === "accepted" || forceAccepted; const base = { error: undefined, showingTermsOfService: { value: showContent && (!accepted || showEvenIfaccepted), button: { - onClick: accepted && !showEvenIfaccepted ? undefined : pushAlertOnError(async () => { - setShowContent(!showContent); - }), + onClick: + accepted && !showEvenIfaccepted + ? undefined + : pushAlertOnError(async () => { + setShowContent(!showContent); + }), }, }, terms: state, termsAccepted: { value: accepted, button: { - onClick: readOnly ? undefined : pushAlertOnError(async () => { - const newValue = !accepted; //toggle - await onUpdate(newValue); - setShowContent(false); - }), + onClick: readOnly + ? undefined + : pushAlertOnError(async () => { + const newValue = !accepted; //toggle + await onUpdate(newValue); + setShowContent(false); + }), }, }, }; @@ -135,20 +149,25 @@ export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, c terms: state, showingTermsOfService: readOnly ? undefined : base.showingTermsOfService, termsAccepted: base.termsAccepted, + skipTos: { + onClick: pushAlertOnError(async () => { + setForceAccepted(true); + }), + }, tosFormat: { onChange: pushAlertOnError(async (s) => { - setFormat(s) + setFormat(s); }), list: supportedFormats, - value: format ?? "" + value: format ?? "", }, tosLang: { onChange: pushAlertOnError(async (s) => { - setTosLang(s) + setTosLang(s); }), list: supportedLangs, - value: tosLang ?? lang - } + value: tosLang ?? lang, + }, }; } //showing buttons @@ -156,5 +175,4 @@ export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, c status: "show-buttons-not-accepted", ...base, }; - } diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx b/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx index 40cfba3bc..f3172a741 100644 --- a/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx +++ b/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx @@ -23,7 +23,7 @@ import { Input, LinkSuccess, TermsOfServiceStyle, - WarningBox + WarningBox, } from "../../components/styled/index.js"; import { Button } from "../../mui/Button.js"; import { State } from "./index.js"; @@ -50,7 +50,9 @@ export function ShowButtonsAcceptedTosView({ </LinkSuccess> </section> {termsAccepted.button.onClick !== undefined && ( - <section style={{ justifyContent: "space-around", display: "flex" }}> + <section + style={{ justifyContent: "space-around", display: "flex" }} + > <CheckboxOutlined name="terms" enabled={termsAccepted.value} @@ -75,36 +77,9 @@ export function ShowButtonsNonAcceptedTosView({ terms, }: State.ShowButtonsNotAccepted): VNode { const { i18n } = useTranslationContext(); - // const ableToReviewTermsOfService = - // showingTermsOfService.button.onClick !== undefined; - - // if (!ableToReviewTermsOfService) { - // return ( - // <Fragment> - // {terms.status === ExchangeTosStatus.Pending && ( - // <section style={{ justifyContent: "space-around", display: "flex" }}> - // <WarningText> - // <i18n.Translate> - // Exchange doesn't have terms of service - // </i18n.Translate> - // </WarningText> - // </section> - // )} - // </Fragment> - // ); - // } return ( <Fragment> - {/* {terms.status === ExchangeTosStatus.NotFound && ( - <section style={{ justifyContent: "space-around", display: "flex" }}> - <WarningText> - <i18n.Translate> - Exchange doesn't have terms of service - </i18n.Translate> - </WarningText> - </section> - )} */} <section style={{ justifyContent: "space-around", display: "flex" }}> <Button variant="contained" @@ -124,11 +99,35 @@ export function ShowTosContentView({ terms, tosLang, tosFormat, + skipTos, }: State.ShowContent): VNode { const { i18n } = useTranslationContext(); - const ableToReviewTermsOfService = - termsAccepted.button.onClick !== undefined; + const ableToReviewTermsOfService = termsAccepted.button.onClick !== undefined; + + if (terms.status === ExchangeTosStatus.MissingTos) { + return ( + <section> + <section style={{ justifyContent: "space-around", display: "flex" }}> + <WarningBox> + <i18n.Translate> + The exchange doesn't have a terms of service. + </i18n.Translate> + </WarningBox> + </section> + <section style={{ justifyContent: "space-around", display: "flex" }}> + <Button + variant="contained" + color="success" + disabled={!skipTos.onClick} + onClick={skipTos.onClick} + > + <i18n.Translate>Skip it for now.</i18n.Translate> + </Button> + </section> + </section> + ); + } return ( <section> <Input style={{ display: "flex", justifyContent: "end" }}> @@ -206,20 +205,21 @@ export function ShowTosContentView({ </LinkSuccess> </section> )} - {termsAccepted.button.onClick && terms.status !== ExchangeTosStatus.Accepted && ( - <section style={{ justifyContent: "space-around", display: "flex" }}> - <CheckboxOutlined - name="terms" - enabled={termsAccepted.value} - label={ - <i18n.Translate> - I accept the exchange terms of service - </i18n.Translate> - } - onToggle={termsAccepted.button.onClick} - /> - </section> - )} + {termsAccepted.button.onClick && + terms.status !== ExchangeTosStatus.Accepted && ( + <section style={{ justifyContent: "space-around", display: "flex" }}> + <CheckboxOutlined + name="terms" + enabled={termsAccepted.value} + label={ + <i18n.Translate> + I accept the exchange terms of service + </i18n.Translate> + } + onToggle={termsAccepted.button.onClick} + /> + </section> + )} </section> ); } diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx index f29d0b0f7..c0bc5532b 100644 --- a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx +++ b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx @@ -39,6 +39,7 @@ import { WxApiType } from "../wxApi.js"; import { WalletActivityTrack } from "../wxBackend.js"; import { Modal } from "./Modal.js"; import { Time } from "./Time.js"; +import { Checkbox } from "./Checkbox.js"; const OPEN_ACTIVITY_HEIGHT_PX = 250; const CLOSE_ACTIVITY_HEIGHT_PX = 40; @@ -212,7 +213,10 @@ function ShowBackupOperationError({ events, onClick }: MoreInfoPRops): VNode { <dd>{error.hint ?? "--"}</dd> <dt>Time</dt> <dd> - <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss" /> + <Time + timestamp={error.when} + format="yyyy/MM/dd HH:mm:ss.SSS" + /> </dd> </dl> <pre @@ -360,34 +364,44 @@ function ShowObservabilityEvent({ events, onClick }: MoreInfoPRops): VNode { const title = (function () { switch (not.event.type) { - case ObservabilityEventType.HttpFetchFinishError: - case ObservabilityEventType.HttpFetchFinishSuccess: case ObservabilityEventType.HttpFetchStart: return "HTTP Request"; - case ObservabilityEventType.DbQueryFinishSuccess: - case ObservabilityEventType.DbQueryFinishError: + case ObservabilityEventType.HttpFetchFinishSuccess: + return "HTTP Request (o)"; + case ObservabilityEventType.HttpFetchFinishError: + return "HTTP Request (x)"; case ObservabilityEventType.DbQueryStart: return "Database"; - case ObservabilityEventType.RequestFinishSuccess: - case ObservabilityEventType.RequestFinishError: + case ObservabilityEventType.DbQueryFinishSuccess: + return "Database (o)"; + case ObservabilityEventType.DbQueryFinishError: + return "Database (x)"; case ObservabilityEventType.RequestStart: return "Wallet"; - case ObservabilityEventType.CryptoFinishSuccess: - case ObservabilityEventType.CryptoFinishError: + case ObservabilityEventType.RequestFinishSuccess: + return "Wallet (o)"; + case ObservabilityEventType.RequestFinishError: + return "Wallet (x)"; case ObservabilityEventType.CryptoStart: return "Crypto"; + case ObservabilityEventType.CryptoFinishSuccess: + return "Crypto (o)"; + case ObservabilityEventType.CryptoFinishError: + return "Crypto (x)"; case ObservabilityEventType.TaskStart: - return "Task start"; + return "Task"; case ObservabilityEventType.TaskStop: - return "Task stop"; + return "Task (s)"; case ObservabilityEventType.TaskReset: - return "Task reset"; + return "Task (r)"; case ObservabilityEventType.ShepherdTaskResult: return "Schedule"; case ObservabilityEventType.DeclareTaskDependency: return "Task dependency"; case ObservabilityEventType.Message: return "Message"; + case ObservabilityEventType.DeclareConcernsTransaction: + return "DeclareConcernsTransaction"; } })(); @@ -401,12 +415,11 @@ function ShowObservabilityEvent({ events, onClick }: MoreInfoPRops): VNode { ); }); return ( - <table> + <table style={{ width: "100%" }}> <thead> <td>Event</td> <td>Info</td> - <td>Start</td> - <td>End</td> + <td>When</td> </thead> <tbody>{asd}</tbody> </table> @@ -417,11 +430,9 @@ function ShowObervavilityDetails({ title, notif, onClick, - prev, }: { title: string; notif: ObservaNotifWithTime; - prev?: ObservaNotifWithTime; onClick: (content: VNode) => void; }): VNode { switch (notif.event.type) { @@ -443,7 +454,7 @@ function ShowObervavilityDetails({ wordBreak: "break-word", }} > - {JSON.stringify({ event: notif, prev }, undefined, 2)} + {JSON.stringify({ event: notif }, undefined, 2)} </pre> </Fragment>, ); @@ -454,21 +465,21 @@ function ShowObervavilityDetails({ </td> <td> {notif.event.url}{" "} - {prev?.event.type === + {notif?.event.type === ObservabilityEventType.HttpFetchFinishSuccess ? ( - `(${prev.event.status})` - ) : prev?.event.type === + `(${notif.event.status})` + ) : notif?.event.type === ObservabilityEventType.HttpFetchFinishError ? ( <a href="#" onClick={(e) => { e.preventDefault(); if ( - prev.event.type !== + notif.event.type !== ObservabilityEventType.HttpFetchFinishError ) return; - const error = prev.event.error; + const error = notif.event.error; onClick( <Fragment> <dl> @@ -482,7 +493,7 @@ function ShowObervavilityDetails({ <dd> <Time timestamp={error.when} - format="yyyy/MM/dd HH:mm:ss" + format="yyyy/MM/dd HH:mm:ss.SSS" /> </dd> </dl> @@ -504,11 +515,7 @@ function ShowObervavilityDetails({ </td> <td> {" "} - <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> - </td> - <td> - {" "} - <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss.SSS" /> </td> </tr> ); @@ -531,7 +538,7 @@ function ShowObervavilityDetails({ wordBreak: "break-word", }} > - {JSON.stringify({ event: notif, prev }, undefined, 2)} + {JSON.stringify({ event: notif }, undefined, 2)} </pre> </Fragment>, ); @@ -544,10 +551,7 @@ function ShowObervavilityDetails({ {notif.event.location} {notif.event.name} </td> <td> - <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> - </td> - <td> - <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss.SSS" /> </td> </tr> ); @@ -572,7 +576,7 @@ function ShowObervavilityDetails({ wordBreak: "break-word", }} > - {JSON.stringify({ event: notif, prev }, undefined, 2)} + {JSON.stringify({ event: notif }, undefined, 2)} </pre> </Fragment>, ); @@ -583,10 +587,7 @@ function ShowObervavilityDetails({ </td> <td>{notif.event.taskId}</td> <td> - <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> - </td> - <td> - <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss.SSS" /> </td> </tr> ); @@ -607,7 +608,7 @@ function ShowObervavilityDetails({ wordBreak: "break-word", }} > - {JSON.stringify({ event: notif, prev }, undefined, 2)} + {JSON.stringify({ event: notif }, undefined, 2)} </pre> </Fragment>, ); @@ -618,10 +619,7 @@ function ShowObervavilityDetails({ </td> <td>{notif.event.resultType}</td> <td> - <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> - </td> - <td> - <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss.SSS" /> </td> </tr> ); @@ -644,7 +642,7 @@ function ShowObervavilityDetails({ wordBreak: "break-word", }} > - {JSON.stringify({ event: notif, prev }, undefined, 2)} + {JSON.stringify({ event: notif }, undefined, 2)} </pre> </Fragment>, ); @@ -655,10 +653,7 @@ function ShowObervavilityDetails({ </td> <td>{notif.event.operation}</td> <td> - <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> - </td> - <td> - <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss.SSS" /> </td> </tr> ); @@ -681,7 +676,7 @@ function ShowObervavilityDetails({ wordBreak: "break-word", }} > - {JSON.stringify({ event: notif, prev }, undefined, 2)} + {JSON.stringify({ event: notif }, undefined, 2)} </pre> </Fragment>, ); @@ -692,27 +687,30 @@ function ShowObervavilityDetails({ </td> <td>{notif.event.type}</td> <td> - <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> - </td> - <td> - <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss.SSS" /> </td> </tr> ); } + case ObservabilityEventType.DeclareConcernsTransaction: case ObservabilityEventType.Message: // FIXME return <></>; } } +function createTabId(tab: chrome.tabs.Tab | undefined) { + return !tab ? "popup" : `${tab.windowId}:${tab.id}`; +} + function refresh( api: WxApiType, onUpdate: (list: WalletActivityTrack[]) => void, filter: string, + fromView?: chrome.tabs.Tab, ) { api.background - .call("getNotifications", { filter }) + .call("getNotifications", { filter, operationsFrom: createTabId(fromView) }) .then((notif) => { onUpdate(notif); }) @@ -721,6 +719,15 @@ function refresh( }); } +let currentTab: chrome.tabs.Tab | undefined; +// Allow running outside the extension for testing +// tslint:disable-next-line:no-string-literal +if (typeof chrome !== "undefined") { + chrome.tabs.getCurrent().then((d) => { + currentTab = d; + }); +} + export function ObservabilityEventsTable(): VNode { const { i18n } = useTranslationContext(); const api = useBackendContext(); @@ -728,11 +735,17 @@ export function ObservabilityEventsTable(): VNode { const [notifications, setNotifications] = useState<WalletActivityTrack[]>([]); const [showDetails, setShowDetails] = useState<VNode>(); const [filter, onChangeFilter] = useState(""); + const [onlyThisScreen, setOnlyThisScreen] = useState(true); useEffect(() => { let lastTimeout: ReturnType<typeof setTimeout>; function periodicRefresh() { - refresh(api, setNotifications, filter); + refresh( + api, + setNotifications, + filter, + onlyThisScreen ? currentTab : undefined, + ); lastTimeout = setTimeout(() => { periodicRefresh(); @@ -743,7 +756,7 @@ export function ObservabilityEventsTable(): VNode { }; } return periodicRefresh(); - }, [filter]); + }, [filter, onlyThisScreen]); return ( <div> @@ -754,6 +767,12 @@ export function ObservabilityEventsTable(): VNode { value={filter} onChange={onChangeFilter} /> + <Checkbox + label={i18n.str`All events`} + name="terms" + onToggle={async () => setOnlyThisScreen((v) => !v)} + enabled={!onlyThisScreen} + /> <div style={{ padding: 4, @@ -763,7 +782,12 @@ export function ObservabilityEventsTable(): VNode { }} onClick={() => { api.background.call("clearNotifications", undefined).then(() => { - refresh(api, setNotifications, filter); + refresh( + api, + setNotifications, + filter, + onlyThisScreen ? currentTab : undefined, + ); }); }} > @@ -784,7 +808,7 @@ export function ObservabilityEventsTable(): VNode { )} {notifications.map((not) => { return ( - <details key={not.id}> + <details key={not.groupId}> <summary> <div style={{ @@ -829,10 +853,13 @@ export function ObservabilityEventsTable(): VNode { })()} </div> <div style={{ padding: 4 }}> - <Time timestamp={not.start} format="yyyy/MM/dd HH:mm:ss" /> + <Time + timestamp={not.start} + format="yyyy/MM/dd HH:mm:ss.SSS" + /> </div> <div style={{ padding: 4 }}> - <Time timestamp={not.end} format="yyyy/MM/dd HH:mm:ss" /> + <Time timestamp={not.end} format="yyyy/MM/dd HH:mm:ss.SSS" /> </div> </div> </summary> @@ -936,7 +963,7 @@ function ErroDetailModal({ <dd>{error.hint ?? "--"}</dd> <dt>Time</dt> <dd> - <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss" /> + <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss.SSS" /> </dd> </dl> <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> @@ -1009,11 +1036,14 @@ export function ActiveTasksTable(): VNode { <td> <Time timestamp={task.firstTry} - format="yyyy/MM/dd HH:mm:ss" + format="yyyy/MM/dd HH:mm:ss.SSS" /> </td> <td> - <Time timestamp={task.nextTry} format="yyyy/MM/dd HH:mm:ss" /> + <Time + timestamp={task.nextTry} + format="yyyy/MM/dd HH:mm:ss.SSS" + /> </td> <td> {!task.lastError?.code ? ( diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts index fd3fb52f8..1ca7481be 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts +++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts @@ -47,6 +47,7 @@ export namespace State { export interface LoadingUriError { status: "error"; + retry: ButtonHandler; error: ErrorAlert; } diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts index daa3ee76d..baaa9a3dd 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts +++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts @@ -34,6 +34,7 @@ export function useComponentState({ }: Props): RecursiveState<State> { const amount = Amounts.parseOrThrow(amountStr); const api = useBackendContext(); + const { pushAlertOnError } = useAlertContext(); const hook = useAsyncAsHook(() => api.wallet.call(WalletApiOperation.ListExchanges, {}), @@ -49,6 +50,11 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", + retry: { + onClick: pushAlertOnError(async () => { + hook.retry(); + }), + }, error: alertFromError( i18n, i18n.str`Could not load the list of exchanges`, @@ -103,6 +109,11 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", + retry: { + onClick: pushAlertOnError(async () => { + hook.retry(); + }), + }, error: alertFromError( i18n, i18n.str`Could not load the invoice status`, diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/index.ts b/packages/taler-wallet-webextension/src/cta/InvoicePay/index.ts index f0cd63fbe..dcb1f827b 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoicePay/index.ts +++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/index.ts @@ -50,6 +50,7 @@ export namespace State { export interface LoadingUriError { status: "error"; + retry: ButtonHandler; error: ErrorAlert; } diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts b/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts index 99de03d2d..deee83751 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts +++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts @@ -63,6 +63,11 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", + retry: { + onClick: pushAlertOnError(async () => { + hook.retry(); + }), + }, error: alertFromError( i18n, i18n.str`Could not load the transfer payment status`, diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/index.ts b/packages/taler-wallet-webextension/src/cta/TransferCreate/index.ts index 794d2ad1c..539ca207c 100644 --- a/packages/taler-wallet-webextension/src/cta/TransferCreate/index.ts +++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/index.ts @@ -14,7 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson, AmountString, TalerErrorDetail } from "@gnu-taler/taler-util"; +import { + AmountJson, + AmountString, + TalerErrorDetail, +} from "@gnu-taler/taler-util"; import { ErrorAlertView } from "../../components/CurrentAlerts.js"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; @@ -39,6 +43,7 @@ export namespace State { export interface LoadingUriError { status: "error"; + retry: ButtonHandler; error: ErrorAlert; } diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts b/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts index f092801ed..f15d48c23 100644 --- a/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts +++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts @@ -57,6 +57,11 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", + retry: { + onClick: pushAlertOnError(async () => { + hook.retry(); + }), + }, error: alertFromError( i18n, i18n.str`Could not load the max amount to transfer`, diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/index.ts b/packages/taler-wallet-webextension/src/cta/TransferPickup/index.ts index 4e1301d6a..a7bb0b67a 100644 --- a/packages/taler-wallet-webextension/src/cta/TransferPickup/index.ts +++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/index.ts @@ -14,16 +14,12 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - AbsoluteTime, - AmountJson, - TalerErrorDetail, -} from "@gnu-taler/taler-util"; +import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util"; import { ErrorAlertView } from "../../components/CurrentAlerts.js"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; import { ButtonHandler } from "../../mui/handlers.js"; -import { compose, StateViewMap } from "../../utils/index.js"; +import { StateViewMap, compose } from "../../utils/index.js"; import { useComponentState } from "./state.js"; import { ReadyView } from "./views.js"; @@ -43,6 +39,7 @@ export namespace State { export interface LoadingUriError { status: "error"; + retry: ButtonHandler; error: ErrorAlert; } diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts b/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts index 67f6d9113..28d8c9e70 100644 --- a/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts +++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts @@ -49,6 +49,11 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", + retry: { + onClick: pushAlertOnError(async () => { + hook.retry(); + }), + }, error: alertFromError( i18n, i18n.str`Could not load the invoice payment status`, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts index af1ef213b..418fef505 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts @@ -18,7 +18,7 @@ import { AmountJson, AmountString, CurrencySpecification, - ExchangeListItem + ExchangeListItem, } from "@gnu-taler/taler-util"; import { Loading } from "../../components/Loading.js"; import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js"; @@ -85,7 +85,7 @@ export namespace State { operationState: "confirmed" | "aborted" | "selected"; thisWallet: boolean; redirectToTx: () => void; - confirmTransferUrl?: string, + confirmTransferUrl?: string; error: undefined; } @@ -99,8 +99,8 @@ export namespace State { editableAmount: boolean; bankFee: AmountJson; - withdrawalFee: AmountJson; toBeReceived: AmountJson; + toBeSent: AmountJson; doWithdrawal: ButtonHandler; doSelectExchange: ButtonHandler; @@ -109,10 +109,12 @@ export namespace State { chooseCurrencies: string[]; selectedCurrency: string; changeCurrency: (s: string) => void; - conversionInfo: { - spec: CurrencySpecification, - amount: AmountJson, - } | undefined; + conversionInfo: + | { + spec: CurrencySpecification; + amount: AmountJson; + } + | undefined; ageRestriction?: SelectFieldHandler; diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts index f8e27e688..0541bbf3f 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -391,7 +391,6 @@ function exchangeSelectionState( const safeAmount = wInfo.amount ? wInfo.amount : Amounts.zeroOfCurrency(wInfo.currency); - const [choosenAmount, setChoosenAmount] = useState(safeAmount); if (selectedExchange.status !== "ready") { return selectedExchange; @@ -403,8 +402,9 @@ function exchangeSelectionState( | State.Loading => { const { i18n } = useTranslationContext(); const { pushAlertOnError } = useAlertContext(); + + const [choosenAmount, setChoosenAmount] = useState(safeAmount); const [ageRestricted, setAgeRestricted] = useState(0); - const currentExchange = selectedExchange.selected; const [selectedCurrency, setSelectedCurrency] = useState<string>( wInfo.currency, @@ -417,7 +417,7 @@ function exchangeSelectionState( const info = await api.wallet.call( WalletApiOperation.GetWithdrawalDetailsForAmount, { - exchangeBaseUrl: currentExchange.exchangeBaseUrl, + exchangeBaseUrl: selectedExchange.selected.exchangeBaseUrl, amount: Amounts.stringify(choosenAmount), restrictAge: ageRestricted, }, @@ -430,13 +430,33 @@ function exchangeSelectionState( return { amount: withdrawAmount, + currentExchange: selectedExchange.selected, ageRestrictionOptions: info.ageRestrictionOptions, accounts: info.withdrawalAccountsList, }; - }, []); + }, [choosenAmount, selectedExchange.selected, ageRestricted]); const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); + if (!amountHook) { + return { status: "loading", error: undefined }; + } + if (amountHook.hasError) { + return { + status: "error", + error: alertFromError( + i18n, + i18n.str`Could not load the withdrawal details`, + amountHook, + ), + }; + } + if (!amountHook.response) { + return { status: "loading", error: undefined }; + } + + const currentExchange = amountHook.response.currentExchange; + async function doWithdrawAndCheckError(): Promise<void> { try { setDoingWithdraw(true); @@ -458,30 +478,10 @@ function exchangeSelectionState( setDoingWithdraw(false); } - if (!amountHook) { - return { status: "loading", error: undefined }; - } - if (amountHook.hasError) { - return { - status: "error", - error: alertFromError( - i18n, - i18n.str`Could not load the withdrawal details`, - amountHook, - ), - }; - } - if (!amountHook.response) { - return { status: "loading", error: undefined }; - } - - const withdrawalFee = Amounts.sub( - amountHook.response.amount.raw, - amountHook.response.amount.effective, - ).amount; + const toBeSent = amountHook.response.amount.raw; const toBeReceived = amountHook.response.amount.effective; - const bankFee = wInfo.amount; + const bankFee = wInfo.bankFee; const ageRestrictionOptions = amountHook.response.ageRestrictionOptions?.reduce( @@ -544,6 +544,7 @@ function exchangeSelectionState( editableExchange: wInfo.editableExchange, currentExchange, toBeReceived, + toBeSent, chooseCurrencies, bankFee, selectedCurrency, @@ -551,7 +552,6 @@ function exchangeSelectionState( setSelectedCurrency(s); }, conversionInfo, - withdrawalFee, amount: { value: choosenAmount, onInput: wInfo.editableAmount @@ -565,11 +565,11 @@ function exchangeSelectionState( ageRestriction, doWithdrawal: { onClick: - doingWithdraw && !amountError + doingWithdraw || amountError ? undefined : pushAlertOnError(doWithdrawAndCheckError), }, cancel, }; - }, []); + }, [selectedExchange.selected]); } diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx index 1bfafb231..d9b7c380e 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx @@ -48,14 +48,20 @@ export const TermsOfServiceNotYetLoaded = tests.createExample(SuccessView, { currency: "USD", value: 2, fraction: 10000000, - } + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 10000000, value: 1, @@ -72,20 +78,19 @@ export const TermsOfServiceNotYetLoaded = tests.createExample(SuccessView, { export const AlreadyAborted = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "aborted" + operationState: "aborted", }); export const AlreadySelected = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "selected" + operationState: "selected", }); export const AlreadyConfirmed = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "confirmed" + operationState: "confirmed", }); - export const WithSomeFee = tests.createExample(SuccessView, { error: undefined, status: "success", @@ -94,14 +99,20 @@ export const WithSomeFee = tests.createExample(SuccessView, { currency: "USD", value: 2, fraction: 10000000, - } + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 10000000, value: 1, @@ -123,14 +134,20 @@ export const WithoutFee = tests.createExample(SuccessView, { currency: "USD", value: 2, fraction: 0, - } + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -152,14 +169,20 @@ export const EditExchangeUntouched = tests.createExample(SuccessView, { currency: "USD", value: 2, fraction: 10000000, - } + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -181,14 +204,20 @@ export const EditExchangeModified = tests.createExample(SuccessView, { currency: "USD", value: 2, fraction: 10000000, - } + }, + }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -211,15 +240,21 @@ export const WithAgeRestriction = tests.createExample(SuccessView, { currency: "USD", value: 2, fraction: 10000000, - } + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doSelectExchange: {}, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -240,8 +275,14 @@ export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, { currency: "NETZBON", value: 2, fraction: 10000000, - } + }, + }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, }, + chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "NETZBON", doWithdrawal: { onClick: nullFunction }, @@ -249,7 +290,7 @@ export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, @@ -270,27 +311,33 @@ export const WithAlternateCurrenciesEURO = tests.createExample(SuccessView, { currency: "NETZBON", value: 2, fraction: 10000000, - } + }, + }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, }, + chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "EUR", - changeCurrency: () => { }, + changeCurrency: () => {}, conversionInfo: { spec: { - name: "EUR" + name: "EUR", } as CurrencySpecification, amount: { currency: "EUR", fraction: 10000000, value: 1, - } + }, }, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, @@ -311,27 +358,32 @@ export const WithAlternateCurrenciesEURO11 = tests.createExample(SuccessView, { currency: "NETZBON", value: 2, fraction: 10000000, - } + }, }, chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "EUR", - changeCurrency: () => { }, + changeCurrency: () => {}, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, conversionInfo: { spec: { - name: "EUR" + name: "EUR", } as CurrencySpecification, amount: { currency: "EUR", fraction: 10000000, value: 2, - } + }, }, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts index bce5f71e3..5a75cb4be 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -123,7 +123,7 @@ describe("Withdraw CTA states", () => { editableExchange: false, maxAmount: "ARS:1", wireFee: "ARS:0", - }, + }, }, ); @@ -208,7 +208,7 @@ describe("Withdraw CTA states", () => { if (state.status !== "success") return; expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); + expect(state.toBeSent).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.amount.value).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.doWithdrawal.onClick).not.undefined; @@ -302,7 +302,7 @@ describe("Withdraw CTA states", () => { if (state.status !== "success") return; expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); + expect(state.toBeSent).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.amount.value).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.doWithdrawal.onClick).not.undefined; diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx index 86d7248a4..b6a356de8 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx @@ -39,6 +39,7 @@ import { getAmountWithFee, } from "../../wallet/Transaction.js"; import { State } from "./index.js"; +import { Amounts } from "@gnu-taler/taler-util"; export function FinalStateOperation(state: State.AlreadyCompleted): VNode { const { i18n } = useTranslationContext(); @@ -143,8 +144,6 @@ export function FinalStateOperation(state: State.AlreadyCompleted): VNode { export function SuccessView(state: State.Success): VNode { const { i18n } = useTranslationContext(); - // const currentTosVersionIsAccepted = - // state.currentExchange.tosStatus === ExchangeTosStatus.Accepted; return ( <Fragment> <section style={{ textAlign: "left" }}> @@ -212,9 +211,10 @@ export function SuccessView(state: State.Success): VNode { conversion={state.conversionInfo?.amount} amount={getAmountWithFee( state.toBeReceived, - state.amount.value, + state.toBeSent, "credit", )} + bankFee={state.bankFee} /> } /> @@ -232,7 +232,6 @@ export function SuccessView(state: State.Success): VNode { </section> <section> - {/* <div> */} <TermsOfService exchangeUrl={state.currentExchange.exchangeBaseUrl}> <Button variant="contained" @@ -245,20 +244,6 @@ export function SuccessView(state: State.Success): VNode { </i18n.Translate> </Button> </TermsOfService> - {/* </div> - <div style={{ marginTop: 20 }}> - <Button - variant="text" - color="success" - - disabled={!state.doAbort.onClick} - onClick={state.doAbort.onClick} - > - <i18n.Translate> - Cancel - </i18n.Translate> - </Button> - </div> */} </section> {state.talerWithdrawUri ? ( <WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} /> diff --git a/packages/taler-wallet-webextension/src/i18n/de.po b/packages/taler-wallet-webextension/src/i18n/de.po index bc66f2136..959ff4007 100644 --- a/packages/taler-wallet-webextension/src/i18n/de.po +++ b/packages/taler-wallet-webextension/src/i18n/de.po @@ -17,8 +17,8 @@ msgstr "" "Project-Id-Version: Taler Wallet\n" "Report-Msgid-Bugs-To: languages@taler.net\n" "POT-Creation-Date: 2016-11-23 00:00+0100\n" -"PO-Revision-Date: 2024-05-07 14:32+0000\n" -"Last-Translator: Stefan Kügel <skuegel@web.de>\n" +"PO-Revision-Date: 2024-06-24 06:32+0000\n" +"Last-Translator: Stefan Kügel <stefan.kuegel@taler.net>\n" "Language-Team: German <https://weblate.taler.net/projects/gnu-taler/" "webextensions/de/>\n" "Language: de\n" @@ -26,7 +26,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.4.3\n" +"X-Generator: Weblate 5.5.5\n" #: src/NavigationBar.tsx:139 #, c-format @@ -1735,8 +1735,8 @@ msgid "" "Please check in your %1$s settings that you have IndexedDB enabled (check " "the preference name %2$s)." msgstr "" -"Bitte prüfen Sie ihre %1$s Einstellungen, für die Sie IndexedDB verwenden (" -"preference name %2$s prüfen)." +"Bitte prüfen Sie in Ihren %1$s-Einstellungen, dass Sie IndexedDB aktiviert " +"haben, und überprüfen Sie %2$s." #: src/components/Diagnostics.tsx:70 #, c-format diff --git a/packages/taler-wallet-webextension/src/platform/api.ts b/packages/taler-wallet-webextension/src/platform/api.ts index 3c116fab2..2388647c1 100644 --- a/packages/taler-wallet-webextension/src/platform/api.ts +++ b/packages/taler-wallet-webextension/src/platform/api.ts @@ -227,6 +227,7 @@ export interface BackgroundPlatformAPI { listenToAllChannels( notifyNewMessage: <Op extends WalletOperations | BackgroundOperations>( message: MessageFromFrontend<Op> & { id: string }, + from: string, ) => Promise<MessageResponse>, ): void; diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts index 056351e3f..276d464a0 100644 --- a/packages/taler-wallet-webextension/src/platform/chrome.ts +++ b/packages/taler-wallet-webextension/src/platform/chrome.ts @@ -53,7 +53,7 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { redirectTabToWalletPage, registerAllIncomingConnections, registerOnInstalled, - listenToAllChannels , + listenToAllChannels, registerReloadOnNewVersion, sendMessageToAllChannels, openNewURLFromPopup, @@ -276,18 +276,20 @@ async function sendMessageToBackground< Op extends WalletOperations | BackgroundOperations, >(message: MessageFromFrontend<Op>): Promise<MessageResponse> { nextMessageIndex = (nextMessageIndex + 1) % (Number.MAX_SAFE_INTEGER - 100); - const messageWithId = { ...message, id: `id_${nextMessageIndex}` }; + const messageWithId = { ...message, id: `fg:${nextMessageIndex}` }; return new Promise<MessageResponse>((resolve, reject) => { logger.trace("send operation to the wallet background", message); let timedout = false; const timerId = setTimeout(() => { timedout = true; - reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, { - requestMethod: "wallet", - requestUrl: message.operation, - timeoutMs: 20 * 1000, - })); + reject( + TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, { + requestMethod: "wallet", + requestUrl: message.operation, + timeoutMs: 20 * 1000, + }), + ); }, 20 * 1000); chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => { if (timedout) { @@ -309,7 +311,9 @@ async function sendMessageToBackground< * To be used by the foreground */ let notificationPort: chrome.runtime.Port | undefined; -function listenToWalletBackground(listener: (message: MessageFromBackend) => void): () => void { +function listenToWalletBackground( + listener: (message: MessageFromBackend) => void, +): () => void { if (notificationPort === undefined) { notificationPort = chrome.runtime.connect({ name: "notifications" }); } @@ -380,13 +384,18 @@ function registerAllIncomingConnections(): void { }); } +function createTabId(tab: chrome.tabs.Tab | undefined) { + return !tab ? "popup" : `${tab.windowId}:${tab.id}`; +} + function listenToAllChannels( notifyNewMessage: <Op extends WalletOperations | BackgroundOperations>( message: MessageFromFrontend<Op> & { id: string }, + id: string, ) => Promise<MessageResponse>, ): void { chrome.runtime.onMessage.addListener((message, sender, reply) => { - notifyNewMessage(message) + notifyNewMessage(message, createTabId(sender.tab)) .then((apiResponse) => { try { reply(apiResponse); @@ -483,26 +492,26 @@ function setAlertedIcon(): void { interface OffscreenCanvasRenderingContext2D extends CanvasState, - CanvasTransform, - CanvasCompositing, - CanvasImageSmoothing, - CanvasFillStrokeStyles, - CanvasShadowStyles, - CanvasFilters, - CanvasRect, - CanvasDrawPath, - CanvasUserInterface, - CanvasText, - CanvasDrawImage, - CanvasImageData, - CanvasPathDrawingStyles, - CanvasTextDrawingStyles, - CanvasPath { + CanvasTransform, + CanvasCompositing, + CanvasImageSmoothing, + CanvasFillStrokeStyles, + CanvasShadowStyles, + CanvasFilters, + CanvasRect, + CanvasDrawPath, + CanvasUserInterface, + CanvasText, + CanvasDrawImage, + CanvasImageData, + CanvasPathDrawingStyles, + CanvasTextDrawingStyles, + CanvasPath { readonly canvas: OffscreenCanvas; } declare const OffscreenCanvasRenderingContext2D: { prototype: OffscreenCanvasRenderingContext2D; - new(): OffscreenCanvasRenderingContext2D; + new (): OffscreenCanvasRenderingContext2D; }; interface OffscreenCanvas extends EventTarget { @@ -515,7 +524,7 @@ interface OffscreenCanvas extends EventTarget { } declare const OffscreenCanvas: { prototype: OffscreenCanvas; - new(width: number, height: number): OffscreenCanvas; + new (width: number, height: number): OffscreenCanvas; }; function createCanvas(size: number): OffscreenCanvas { @@ -760,7 +769,6 @@ function listenNetworkConnectionState( }; } - function runningOnPrivateMode(): boolean { return chrome.extension.inIncognitoContext; } diff --git a/packages/taler-wallet-webextension/src/platform/dev.ts b/packages/taler-wallet-webextension/src/platform/dev.ts index b53e8f3c4..844a5c517 100644 --- a/packages/taler-wallet-webextension/src/platform/dev.ts +++ b/packages/taler-wallet-webextension/src/platform/dev.ts @@ -95,7 +95,7 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { useServiceWorkerAsBackgroundProcess: () => false, listenToAllChannels: ( - notifyNewMessage: (message: any) => Promise<MessageResponse>, + notifyNewMessage: (message: any, from: string) => Promise<MessageResponse>, ) => { window.addEventListener( "message", @@ -103,7 +103,7 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { if (event.data.type !== "command") return; const sender = event.data.header.replyMe; - notifyNewMessage(event.data.body as any).then((resp) => { + notifyNewMessage(event.data.body as any, sender).then((resp) => { logger.trace(`listenToAllChannels: from ${sender}`, event); if (event.source) { const msg: IframeMessageResponse = { @@ -199,4 +199,3 @@ interface IframeMessageCommand { } export default api; - diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts index 3b7cbcbb7..5e781121b 100644 --- a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts +++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts @@ -14,7 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { CoreApiResponse, TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; +import { + CoreApiResponse, + TalerError, + TalerErrorCode, +} from "@gnu-taler/taler-util"; import type { MessageFromBackend } from "./platform/api.js"; /** @@ -51,8 +55,6 @@ const rootElementIsHTML = // "meta[name=taler-support]", // ); - - function validateTalerUri(uri: string): boolean { return ( !!uri && (uri.startsWith("taler://") || uri.startsWith("taler+http://")) @@ -61,7 +63,9 @@ function validateTalerUri(uri: string): boolean { function convertURIToWebExtensionPath(uri: string) { const url = new URL( - chrome.runtime.getURL(`static/wallet.html#/taler-uri/${encodeURIComponent(uri)}`), + chrome.runtime.getURL( + `static/wallet.html#/taler-uri/${encodeURIComponent(uri)}`, + ), ); return url.href; } @@ -75,7 +79,7 @@ const shouldNotInject = !rootElementIsHTML; const logger = { - debug: (...msg: any[]) => { }, + debug: (...msg: any[]) => {}, info: (...msg: any[]) => console.log(`${new Date().toISOString()} TALER`, ...msg), error: (...msg: any[]) => @@ -87,7 +91,7 @@ const logger = { /** */ function redirectToTalerActionHandler(element: HTMLMetaElement) { - const name = element.getAttribute("name") + const name = element.getAttribute("name"); if (!name) return; if (name !== "taler-uri") return; const uri = element.getAttribute("content"); @@ -98,20 +102,20 @@ function redirectToTalerActionHandler(element: HTMLMetaElement) { return; } - const walletPage = convertURIToWebExtensionPath(uri) - window.location.replace(walletPage) + const walletPage = convertURIToWebExtensionPath(uri); + window.location.replace(walletPage); } function injectTalerSupportScript(head: HTMLHeadElement, trusted: boolean) { - const meta = head.querySelector("meta[name=taler-support]") + const meta = head.querySelector("meta[name=taler-support]"); if (!meta) return; const content = meta.getAttribute("content"); if (!content) return; - const features = content.split(",") + const features = content.split(","); const debugEnabled = meta.getAttribute("debug") === "true"; - const hijackEnabled = features.indexOf("uri") !== -1 - const talerApiEnabled = features.indexOf("api") !== -1 && trusted + const hijackEnabled = features.indexOf("uri") !== -1; + const talerApiEnabled = features.indexOf("api") !== -1 && trusted; const scriptTag = document.createElement("script"); scriptTag.setAttribute("async", "false"); @@ -131,14 +135,16 @@ function injectTalerSupportScript(head: HTMLHeadElement, trusted: boolean) { scriptTag.src = url.href; try { - head.insertBefore(scriptTag, head.children.length ? head.children[0] : null); + head.insertBefore( + scriptTag, + head.children.length ? head.children[0] : null, + ); } catch (e) { logger.info("inserting link handler failed!"); logger.error(e); } } - export interface ExtensionOperations { isAutoOpenEnabled: { request: void; @@ -177,31 +183,38 @@ async function callBackground<Op extends keyof ExtensionOperations>( return response.result as any; } - let nextMessageIndex = 0; /** - * - * @param message - * @returns + * + * @param message + * @returns */ async function sendMessageToBackground<Op extends keyof ExtensionOperations>( message: MessageFromExtension<Op>, ): Promise<MessageResponse> { - const messageWithId = { ...message, id: `id_${nextMessageIndex++ % 1000}` }; + const messageWithId = { ...message, id: `ld:${nextMessageIndex++ % 1000}` }; if (!chrome.runtime.id) { - return Promise.reject(TalerError.fromDetail(TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, {})) + return Promise.reject( + TalerError.fromDetail(TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, {}), + ); } return new Promise<any>((resolve, reject) => { - logger.debug("send operation to the wallet background", message, chrome.runtime.id); + logger.debug( + "send operation to the wallet background", + message, + chrome.runtime.id, + ); let timedout = false; const timerId = setTimeout(() => { timedout = true; - reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, { - requestMethod: "wallet", - requestUrl: message.operation, - timeoutMs: 20 * 1000, - })) + reject( + TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, { + requestMethod: "wallet", + requestUrl: message.operation, + timeoutMs: 20 * 1000, + }), + ); }, 20 * 1000); //five seconds try { chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => { @@ -218,7 +231,7 @@ async function sendMessageToBackground<Op extends keyof ExtensionOperations>( return true; }); } catch (e) { - console.log(e) + console.log(e); } }); } @@ -240,71 +253,76 @@ function listenToWalletBackground(listener: (m: any) => void): () => void { const loaderSettings = { isAutoOpenEnabled: false, isDomainTrusted: false, -} +}; function start( onTalerMetaTagFound: (listener: (el: HTMLMetaElement) => void) => void, - onHeadReady: (listener: (el: HTMLHeadElement) => void) => void + onHeadReady: (listener: (el: HTMLHeadElement) => void) => void, ) { // do not run everywhere, this is just expected to run on site // that are aware of taler if (shouldNotInject) return; - const isAutoOpenEnabled_promise = callBackground("isAutoOpenEnabled", undefined).then(result => { + const isAutoOpenEnabled_promise = callBackground( + "isAutoOpenEnabled", + undefined, + ).then((result) => { loaderSettings.isAutoOpenEnabled = result; return result; - }) + }); const isDomainTrusted_promise = callBackground("isDomainTrusted", { - domain: window.location.origin - }).then(result => { + domain: window.location.origin, + }).then((result) => { loaderSettings.isDomainTrusted = result; return result; - }) + }); onTalerMetaTagFound(async (el) => { await isAutoOpenEnabled_promise; if (!loaderSettings.isAutoOpenEnabled) { return; } - redirectToTalerActionHandler(el) - }) + redirectToTalerActionHandler(el); + }); onHeadReady(async (el) => { - const trusted = await isDomainTrusted_promise - injectTalerSupportScript(el, trusted) - }) + const trusted = await isDomainTrusted_promise; + injectTalerSupportScript(el, trusted); + }); listenToWalletBackground((e: MessageFromBackend) => { - if (e.type === "web-extension" && e.notification.type === "settings-change") { - const settings = e.notification.currentValue - loaderSettings.isAutoOpenEnabled = settings.autoOpen + if ( + e.type === "web-extension" && + e.notification.type === "settings-change" + ) { + const settings = e.notification.currentValue; + loaderSettings.isAutoOpenEnabled = settings.autoOpen; } - }) - + }); } function isCorrectMetaElement(el: HTMLMetaElement): boolean { - const name = el.getAttribute("name") + const name = el.getAttribute("name"); if (!name) return false; if (name !== "taler-uri") return false; const uri = el.getAttribute("content"); if (!uri) return false; - return true + return true; } /** * Tries to find taler meta tag ASAP and report - * @param notify - * @returns + * @param notify + * @returns */ function notifyWhenTalerUriIsFound(notify: (el: HTMLMetaElement) => void) { if (document.head) { - const element = document.head.querySelector("meta[name=taler-uri]") + const element = document.head.querySelector("meta[name=taler-uri]"); if (!element) return; if (!(element instanceof HTMLMetaElement)) return; if (isCorrectMetaElement(element)) { - notify(element) + notify(element); } return; } @@ -315,34 +333,33 @@ function notifyWhenTalerUriIsFound(notify: (el: HTMLMetaElement) => void) { mut.addedNodes.forEach((added) => { if (added instanceof HTMLMetaElement) { if (isCorrectMetaElement(added)) { - notify(added) - obs.disconnect() + notify(added); + obs.disconnect(); } } }); } }); } catch (e) { - console.error(e) + console.error(e); } - }) + }); obs.observe(document, { childList: true, subtree: true, attributes: false, - }) - + }); } /** * Tries to find HEAD tag ASAP and report - * @param notify - * @returns + * @param notify + * @returns */ function notifyWhenHeadIsFound(notify: (el: HTMLHeadElement) => void) { if (document.head) { - notify(document.head) + notify(document.head); return; } const obs = new MutationObserver(async function (mutations) { @@ -351,22 +368,22 @@ function notifyWhenHeadIsFound(notify: (el: HTMLHeadElement) => void) { if (mut.type === "childList") { mut.addedNodes.forEach((added) => { if (added instanceof HTMLHeadElement) { - notify(added) - obs.disconnect() + notify(added); + obs.disconnect(); } }); } }); } catch (e) { - console.error(e) + console.error(e); } - }) + }); obs.observe(document, { childList: true, subtree: true, attributes: false, - }) + }); } start(notifyWhenTalerUriIsFound, notifyWhenHeadIsFound); diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx index 8f23c0685..9feb03714 100644 --- a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx @@ -22,7 +22,7 @@ import { LogLevel, NotificationType, ScopeType, - stringifyWithdrawExchange + stringifyWithdrawExchange, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -112,21 +112,21 @@ export function DeveloperPage(): VNode { const currencies: { [ex: string]: string } = {}; const money_by_exchange = coins.reduce( (prev, cur) => { - const denom = Amounts.parseOrThrow(cur.denom_value); - if (!prev[cur.exchange_base_url]) { - prev[cur.exchange_base_url] = []; - currencies[cur.exchange_base_url] = denom.currency; + const denom = Amounts.parseOrThrow(cur.denomValue); + if (!prev[cur.exchangeBaseUrl]) { + prev[cur.exchangeBaseUrl] = []; + currencies[cur.exchangeBaseUrl] = denom.currency; } - prev[cur.exchange_base_url].push({ + prev[cur.exchangeBaseUrl].push({ // ageKeysCount: cur.ageCommitmentProof?.proof.privateKeys.length, denom_value: denom.value, denom_fraction: denom.fraction, // remain_value: parseFloat( // Amounts.stringifyValue(Amounts.parseOrThrow(cur.remaining_value)), // ), - status: cur.coin_status, - from_refresh: cur.refresh_parent_coin_pub !== undefined, - id: cur.coin_pub, + status: cur.coinStatus, + from_refresh: cur.refreshParentCoinPub !== undefined, + id: cur.coinPub, }); return prev; }, @@ -351,7 +351,7 @@ export function DeveloperPage(): VNode { <a href={new URL(`/keys`, e.exchangeBaseUrl).href} target="_blank" - rel="noreferrer" + rel="noreferrer" > {e.exchangeBaseUrl} </a> diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index 1f0293352..339ded173 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -1416,9 +1416,11 @@ export function TransferPickupDetails({ export function WithdrawDetails({ conversion, amount, + bankFee, }: { conversion?: AmountJson; amount: AmountWithFee; + bankFee?: AmountJson; }): VNode { const { i18n } = useTranslationContext(); @@ -1481,6 +1483,16 @@ export function WithdrawDetails({ </tr> </Fragment> )} + {!bankFee || Amounts.isZero(bankFee) ? undefined : ( + <tr> + <td> + <i18n.Translate>Bank fee</i18n.Translate> + </td> + <td> + <Amount value={bankFee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + )} </PurchaseDetailsTable> ); } diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index 47b466fcd..8361a098d 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -31,7 +31,7 @@ import { TalerError, TalerErrorCode, TalerErrorDetail, - WalletNotification + WalletNotification, } from "@gnu-taler/taler-util"; import { WalletCoreApiClient, @@ -55,7 +55,7 @@ import { WalletActivityTrack } from "./wxBackend.js"; const logger = new Logger("wxApi"); -export const WALLET_CORE_SUPPORTED_VERSION = "5:0:0" +export const WALLET_CORE_SUPPORTED_VERSION = "7:0:0"; export interface ExtendedPermissionsResponse { newValue: boolean; @@ -77,6 +77,7 @@ export interface BackgroundOperations { getNotifications: { request: { filter: string; + operationsFrom?: string; }; response: WalletActivityTrack[]; }; @@ -93,7 +94,10 @@ export interface BackgroundOperations { }; } -export type WalletEvent = { notification: WalletNotification, when: AbsoluteTime } +export type WalletEvent = { + notification: WalletNotification; + when: AbsoluteTime; +}; export interface BackgroundApiClient { call<Op extends keyof BackgroundOperations>( @@ -139,10 +143,14 @@ class BackgroundApiClientImpl implements BackgroundApiClient { response = await platform.sendMessageToBackground(message); } catch (error) { if (error instanceof Error) { - throw new BackgroundError(operation, { - code: TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, - when: AbsoluteTime.now(), - }, error); + throw new BackgroundError( + operation, + { + code: TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, + when: AbsoluteTime.now(), + }, + error, + ); } throw error; } @@ -168,6 +176,8 @@ class WalletApiClientImpl implements WalletCoreApiClient { ): Promise<WalletCoreResponseType<Op>> { let response: CoreApiResponse; try { + // FIXME: This type must be fixed and needs documentation! + // @ts-ignore const message: MessageFromFrontendWallet<Op> = { channel: "wallet", operation, @@ -176,10 +186,14 @@ class WalletApiClientImpl implements WalletCoreApiClient { response = await platform.sendMessageToBackground(message); } catch (error) { if (error instanceof Error) { - throw new BackgroundError(operation, { - code: TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, - when: AbsoluteTime.now(), - }, error); + throw new BackgroundError( + operation, + { + code: TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, + when: AbsoluteTime.now(), + }, + error, + ); } throw error; } @@ -187,7 +201,7 @@ class WalletApiClientImpl implements WalletCoreApiClient { throw new BackgroundError( `Wallet operation "${operation}" failed`, response.error, - TalerError.fromUncheckedDetail(response.error) + TalerError.fromUncheckedDetail(response.error), ); } logger.trace("got response", response); @@ -205,7 +219,9 @@ function onUpdateNotification( return; }; const onNewMessage = (message: MessageFromBackend): void => { - const shouldNotify = message.type === "wallet" && messageTypes.includes(message.notification.type); + const shouldNotify = + message.type === "wallet" && + messageTypes.includes(message.notification.type); if (shouldNotify) { doCallback(message.notification); } @@ -226,7 +242,7 @@ function trigger(w: ExtensionNotification) { platform.triggerWalletEvent({ type: "web-extension", notification: w, - }) + }); } export const wxApi = { diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts index a0b9f2908..ab3c465c4 100644 --- a/packages/taler-wallet-webextension/src/wxBackend.ts +++ b/packages/taler-wallet-webextension/src/wxBackend.ts @@ -91,19 +91,15 @@ async function resetDb(): Promise<void> { } export type WalletActivityTrack = { - id: number; + // id: number; events: (WalletNotification & { when: AbsoluteTime })[]; start: AbsoluteTime; type: NotificationType; end: AbsoluteTime; groupId: string; + requestId?: string; //only for request event }; -let counter = 0; -function getUniqueId(): number { - return counter++; -} - //FIXME: maybe circular buffer const activity: WalletActivityTrack[] = []; @@ -120,10 +116,9 @@ function convertWalletActivityNotification( if (found) { found.end = event.when; found.events.unshift(event); - return found; + return undefined; } return { - id: getUniqueId(), type: event.type, start: event.when, end: AbsoluteTime.never(), @@ -134,7 +129,6 @@ function convertWalletActivityNotification( case NotificationType.BackupOperationError: { const groupId = ""; return { - id: getUniqueId(), type: event.type, start: event.when, end: AbsoluteTime.never(), @@ -148,10 +142,9 @@ function convertWalletActivityNotification( if (found) { found.end = event.when; found.events.unshift(event); - return found; + return undefined; } return { - id: getUniqueId(), type: event.type, start: event.when, end: AbsoluteTime.never(), @@ -168,10 +161,9 @@ function convertWalletActivityNotification( if (found) { found.end = event.when; found.events.unshift(event); - return found; + return undefined; } return { - id: getUniqueId(), type: event.type, start: event.when, end: AbsoluteTime.never(), @@ -181,14 +173,13 @@ function convertWalletActivityNotification( } case NotificationType.Idle: { const groupId = ""; - return({ - id: getUniqueId(), + return { type: event.type, start: event.when, end: AbsoluteTime.never(), events: [event], groupId, - }); + }; } case NotificationType.TaskObservabilityEvent: { const groupId = `${event.type}:${event.taskId}`; @@ -196,16 +187,15 @@ function convertWalletActivityNotification( if (found) { found.end = event.when; found.events.unshift(event); - return found; + return undefined; } - return({ - id: getUniqueId(), + return { type: event.type, start: event.when, end: AbsoluteTime.never(), events: [event], groupId, - }); + }; } case NotificationType.RequestObservabilityEvent: { const groupId = `${event.type}:${event.operation}:${event.requestId}`; @@ -213,16 +203,16 @@ function convertWalletActivityNotification( if (found) { found.end = event.when; found.events.unshift(event); - return found; + return undefined; } - return({ - id: getUniqueId(), + return { type: event.type, start: event.when, end: AbsoluteTime.never(), events: [event], groupId, - }); + requestId: event.requestId, + }; } } } @@ -241,14 +231,24 @@ function addNewWalletActivityNotification( async function getNotifications({ filter, + operationFrom, }: { filter: string; + operationFrom?: string; }): Promise<WalletActivityTrack[]> { - if (!filter) return activity; - const rg = new RegExp(`.*${filter}.*`); return activity.filter((event) => { - return rg.test(event.groupId.toLowerCase()); + if (operationFrom) { + if (event.type !== NotificationType.RequestObservabilityEvent) { + return false; + } + if (event.requestId) { + return event.requestId.startsWith(operationFrom); + } + } + if (!filter) return true; + const testFilter = rg.test(event.groupId.toLowerCase()); + return testFilter; }); } @@ -318,7 +318,10 @@ let nextMessageIndex = 0; async function dispatch< Op extends WalletOperations | BackgroundOperations | ExtensionOperations, ->(req: MessageFromFrontend<Op> & { id: string }): Promise<MessageResponse> { +>( + req: MessageFromFrontend<Op> & { id: string }, + from: string, +): Promise<MessageResponse> { nextMessageIndex = (nextMessageIndex + 1) % (Number.MAX_SAFE_INTEGER - 100); switch (req.channel) { @@ -402,7 +405,7 @@ async function dispatch< }; } //multiple client can create the same id, send the wallet an unique key - const newId = `${req.id}_${nextMessageIndex}`; + const newId = `${from}:${req.id}`; const resp = await w.handleCoreApiRequest( req.operation, newId, @@ -516,10 +519,10 @@ export async function wxMain(): Promise<void> { // Handlers for messages coming directly from the content // script on the page logger.trace("listen all channels"); - platform.listenToAllChannels(async (message) => { + platform.listenToAllChannels(async (message, from) => { //wait until wallet is initialized await afterWalletIsInitialized; - const result = await dispatch(message); + const result = await dispatch(message, from); return result; }); diff --git a/packages/web-util/package.json b/packages/web-util/package.json index c6bf20160..fe8a4a3f7 100644 --- a/packages/web-util/package.json +++ b/packages/web-util/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/web-util", - "version": "0.11.4", + "version": "0.12.2", "description": "Generic helper functionality for GNU Taler Web Apps", "type": "module", "types": "./lib/index.node.d.ts", diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts index 2725dc7e1..3e3ad2d13 100644 --- a/packages/web-util/src/context/translation.ts +++ b/packages/web-util/src/context/translation.ts @@ -95,7 +95,7 @@ export const TranslationProvider = ({ if (forceLang) { changeLanguage(forceLang); } - }); + },[forceLang]); useEffect(() => { setupI18n(lang, source); }, [lang]); diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts index 23d3af468..944e65945 100644 --- a/packages/web-util/src/utils/request.ts +++ b/packages/web-util/src/utils/request.ts @@ -51,6 +51,7 @@ export async function defaultRequestHandler<T>( `${options.basicAuth.username}:${options.basicAuth.password}`, )}`; } + requestHeaders["Content-Type"] = !options.contentType || options.contentType === "json" ? "application/json" : "text/plain"; |