diff options
author | Sebastian <sebasjm@gmail.com> | 2023-09-04 14:17:55 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-09-04 14:17:55 -0300 |
commit | e1d86816a7c07cb8ca2d54676d5cdbbe513f2ba7 (patch) | |
tree | d4ed5506ab3550a7e9b1a082d7ffeddf9f3c4954 | |
parent | ff20c3e25e076c24f7cb93eabe58b6f934f51f35 (diff) |
backoffcie new version, lot of changes
90 files changed, 4658 insertions, 1576 deletions
diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx index f6a81ff8d..5e82821ae 100644 --- a/packages/merchant-backoffice-ui/src/Application.tsx +++ b/packages/merchant-backoffice-ui/src/Application.tsx @@ -19,19 +19,20 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { HttpStatusCode, LibtoolVersion } from "@gnu-taler/taler-util"; import { ErrorType, TranslationProvider, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, VNode, h } from "preact"; import { route } from "preact-router"; -import { useMemo, useState } from "preact/hooks"; +import { useMemo } from "preact/hooks"; import { ApplicationReadyRoutes } from "./ApplicationReadyRoutes.js"; import { Loading } from "./components/exception/loading.js"; import { - NotificationCard, - NotYetReadyAppMenu, + NotConnectedAppMenu, + NotificationCard } from "./components/menu/index.js"; import { BackendContextProvider, @@ -41,23 +42,24 @@ import { ConfigContextProvider } from "./context/config.js"; import { useBackendConfig } from "./hooks/backend.js"; import { strings } from "./i18n/strings.js"; import LoginPage from "./paths/login/index.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { Settings } from "./paths/settings/index.js"; export function Application(): VNode { return ( - // <FetchContextProvider> <BackendContextProvider> <TranslationProvider source={strings}> <ApplicationStatusRoutes /> </TranslationProvider> </BackendContextProvider> - // </FetchContextProvider> ); } +/** + * Check connection testing against /config + * + * @returns + */ function ApplicationStatusRoutes(): VNode { - const { updateLoginStatus, triedToLog } = useBackendContext(); + const { url, updateLoginStatus, triedToLog } = useBackendContext(); const result = useBackendConfig(); const { i18n } = useTranslationContext(); @@ -71,19 +73,10 @@ function ApplicationStatusRoutes(): VNode { : { currency: "unknown", version: "unknown" }; const ctx = useMemo(() => ({ currency, version }), [currency, version]); - const [showSettings, setShowSettings] = useState(false) - - if (showSettings) { - return <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" /> - <Settings /> - </Fragment> - } - if (!triedToLog) { return ( <Fragment> - <NotYetReadyAppMenu title="Welcome!" onShowSettings={() => setShowSettings(true)} /> + <NotConnectedAppMenu title="Welcome!" /> <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> </Fragment> ); @@ -97,7 +90,7 @@ function ApplicationStatusRoutes(): VNode { ) { return ( <Fragment> - <NotYetReadyAppMenu title="Login" onShowSettings={() => setShowSettings(true)} /> + <NotConnectedAppMenu title="Login" /> <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> </Fragment> ); @@ -108,7 +101,7 @@ function ApplicationStatusRoutes(): VNode { ) { return ( <Fragment> - <NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} /> + <NotConnectedAppMenu title="Error" /> <NotificationCard notification={{ message: i18n.str`Server not found`, @@ -122,7 +115,7 @@ function ApplicationStatusRoutes(): VNode { } if (result.type === ErrorType.SERVER) { <Fragment> - <NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} /> + <NotConnectedAppMenu title="Error" /> <NotificationCard notification={{ message: i18n.str`Server response with an error code`, @@ -135,7 +128,7 @@ function ApplicationStatusRoutes(): VNode { } if (result.type === ErrorType.UNREADABLE) { <Fragment> - <NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} /> + <NotConnectedAppMenu title="Error" /> <NotificationCard notification={{ message: i18n.str`Response from server is unreadable, http status: ${result.status}`, @@ -148,7 +141,7 @@ function ApplicationStatusRoutes(): VNode { } return ( <Fragment> - <NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} /> + <NotConnectedAppMenu title="Error" /> <NotificationCard notification={{ message: i18n.str`Unexpected Error`, @@ -161,6 +154,25 @@ function ApplicationStatusRoutes(): VNode { ); } + const SUPPORTED_VERSION = "5:0:1" + if (!LibtoolVersion.compare( + SUPPORTED_VERSION, + result.data.version, + )?.compatible) { + return <Fragment> + <NotConnectedAppMenu title="Error" /> + <NotificationCard + notification={{ + message: i18n.str`Incompatible version`, + type: "ERROR", + description: i18n.str`Merchant backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`, + }} + /> + <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> + </Fragment> + + } + return ( <div class="has-navbar-fixed-top"> <ConfigContextProvider value={ctx}> diff --git a/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx b/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx index 277c2b176..46dea98e3 100644 --- a/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx +++ b/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx @@ -22,7 +22,7 @@ import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; import { createHashHistory } from "history"; import { Fragment, h, VNode } from "preact"; import { Router, Route, route } from "preact-router"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { NotificationCard, NotYetReadyAppMenu, @@ -35,52 +35,55 @@ import { INSTANCE_ID_LOOKUP } from "./utils/constants.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; import { Settings } from "./paths/settings/index.js"; +/** + * Check if admin against /management/instances + * @returns + */ export function ApplicationReadyRoutes(): VNode { const { i18n } = useTranslationContext(); + const [unauthorized, setUnauthorized] = useState(false) const { url: backendURL, - updateLoginStatus, - clearAllTokens, + updateLoginStatus: updateLoginStatus2, } = useBackendContext(); + function updateLoginStatus(url: string, token: string | undefined) { + console.log("updateing", url, token) + updateLoginStatus2(url, token) + setUnauthorized(false) + } + const result = useBackendInstancesTestForAdmin(); const clearTokenAndGoToRoot = () => { - clearAllTokens(); route("/"); }; const [showSettings, setShowSettings] = useState(false) + // useEffect(() => { + // setUnauthorized(FF) + // }, [FF]) + const unauthorizedAdmin = !result.loading && !result.ok && result.type === ErrorType.CLIENT && result.status === HttpStatusCode.Unauthorized if (showSettings) { return <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} /> - <Settings/> + <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> + <Settings /> </Fragment> } - if (result.loading) return <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Loading..." />; - let admin = true; - let instanceNameByBackendURL; + if (result.loading) { + return <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Loading..." isPasswordOk={false} />; + } - if (!result.ok) { - if ( - result.type === ErrorType.CLIENT && - result.status === HttpStatusCode.Unauthorized - ) { - return ( - <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} /> - <NotificationCard - notification={{ - message: i18n.str`Access denied`, - description: i18n.str`Check your token is valid`, - type: "ERROR", - }} - /> - <LoginPage onConfirm={updateLoginStatus} /> - </Fragment> - ); - } + let admin = result.ok || unauthorizedAdmin; + let instanceNameByBackendURL: string | undefined; + + 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]) { @@ -89,7 +92,7 @@ export function ApplicationReadyRoutes(): VNode { // does not match our pattern return ( <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Error" onLogout={clearTokenAndGoToRoot} /> + <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Error" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> <NotificationCard notification={{ message: i18n.str`Couldn't access the server.`, @@ -102,10 +105,24 @@ export function ApplicationReadyRoutes(): VNode { ); } - admin = false; instanceNameByBackendURL = match[1]; } + console.log(unauthorized, unauthorizedAdmin) + if (unauthorized || unauthorizedAdmin) { + return <Fragment> + <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> + <NotificationCard + notification={{ + message: i18n.str`Access denied`, + description: i18n.str`Check your token is valid`, + type: "ERROR", + }} + /> + <LoginPage onConfirm={updateLoginStatus} /> + </Fragment> + } + const history = createHashHistory(); return ( <Router history={history}> @@ -113,6 +130,11 @@ export function ApplicationReadyRoutes(): VNode { default component={DefaultMainRoute} admin={admin} + onUnauthorized={() => setUnauthorized(true)} + onLoginPass={() => { + console.log("ahora si") + setUnauthorized(false) + }} instanceNameByBackendURL={instanceNameByBackendURL} /> </Router> @@ -122,6 +144,8 @@ export function ApplicationReadyRoutes(): VNode { function DefaultMainRoute({ instance, admin, + onUnauthorized, + onLoginPass, instanceNameByBackendURL, url, //from preact-router }: any): VNode { @@ -133,6 +157,8 @@ function DefaultMainRoute({ <InstanceRoutes admin={admin} path={url} + onUnauthorized={onUnauthorized} + onLoginPass={onLoginPass} id={instanceName} setInstanceName={setInstanceName} /> diff --git a/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx b/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx index 1547442ea..4a4b3fee4 100644 --- a/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx +++ b/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx @@ -40,6 +40,7 @@ import { 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"; @@ -47,6 +48,9 @@ import OrderListPage from "./paths/instance/orders/list/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"; @@ -58,6 +62,9 @@ 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/validators/create/index.js"; +import ValidatorListPage from "./paths/instance/validators/list/index.js"; +import ValidatorUpdatePage from "./paths/instance/validators/update/index.js"; import TransferCreatePage from "./paths/instance/transfers/create/index.js"; import TransferListPage from "./paths/instance/transfers/list/index.js"; import InstanceUpdatePage, { @@ -69,11 +76,16 @@ import NotFoundPage from "./paths/notfound/index.js"; import { Notification } from "./utils/types.js"; import { MerchantBackend } from "./declaration.js"; import { Settings } from "./paths/settings/index.js"; +import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js"; export enum InstancePaths { - // details = '/', error = "/error", - update = "/update", + server = "/server", + token = "/token", + + bank_list = "/bank", + bank_update = "/bank/:bid/update", + bank_new = "/bank/new", product_list = "/products", product_update = "/product/:pid/update", @@ -102,11 +114,15 @@ export enum InstancePaths { webhooks_update = "/webhooks/:tid/update", webhooks_new = "/webhooks/new", - settings = "/settings", + validators_list = "/validators", + validators_update = "/validators/:vid/update", + validators_new = "/validators/new", + + settings = "/inteface", } // eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; +const noop = () => { }; export enum AdminPaths { list_instances = "/instances", @@ -118,6 +134,8 @@ export interface Props { id: string; admin?: boolean; path: string; + onUnauthorized: () => void; + onLoginPass: () => void; setInstanceName: (s: string) => void; } @@ -125,40 +143,29 @@ export function InstanceRoutes({ id, admin, path, + onUnauthorized, + onLoginPass, setInstanceName, }: Props): VNode { - const [_, updateDefaultToken] = useBackendDefaultToken(); + const [defaultToken, updateDefaultToken] = useBackendDefaultToken(); const [token, updateToken] = useBackendInstanceToken(id); - const { - updateLoginStatus: changeBackend, - addTokenCleaner, - clearAllTokens, - } = useBackendContext(); - const cleaner = useCallback(() => { - updateToken(undefined); - }, [id]); const { i18n } = useTranslationContext(); type GlobalNotifState = (Notification & { to: string }) | undefined; const [globalNotification, setGlobalNotification] = useState<GlobalNotifState>(undefined); - useEffect(() => { - addTokenCleaner(cleaner); - }, [addTokenCleaner, cleaner]); - const changeToken = (token?: string) => { if (admin) { updateToken(token); } else { updateDefaultToken(token); } + onLoginPass() }; - const updateLoginStatus = (url: string, token?: string) => { - changeBackend(url); - if (!token) return; - changeToken(token); - }; + // const updateLoginStatus = (url: string, token?: string) => { + // changeToken(token); + // }; const value = useMemo( () => ({ id, token, admin, changeToken }), @@ -192,18 +199,17 @@ export function InstanceRoutes({ }; } - const LoginPageAccessDenied = () => ( - <Fragment> - <NotificationCard - notification={{ - message: i18n.str`Access denied`, - description: i18n.str`The access token provided is invalid.`, - type: "ERROR", - }} - /> - <LoginPage onConfirm={updateLoginStatus} /> - </Fragment> - ); + // const LoginPageAccessDeniend = onUnauthorized + const LoginPageAccessDenied = () => { + onUnauthorized() + return <NotificationCard + notification={{ + message: i18n.str`Access denied`, + description: i18n.str`Redirecting to login page.`, + type: "ERROR", + }} + /> + } function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) { return function IfAdminCreateDefaultOrImpl(props?: T) { @@ -234,8 +240,10 @@ export function InstanceRoutes({ } const clearTokenAndGoToRoot = () => { - clearAllTokens(); route("/"); + // clear all tokens + updateToken(undefined) + updateDefaultToken(undefined) }; return ( @@ -244,11 +252,12 @@ export function InstanceRoutes({ instance={id} admin={admin} onShowSettings={() => { - route("/settings") + route("/inteface") }} path={path} onLogout={clearTokenAndGoToRoot} setInstanceName={setInstanceName} + isPasswordOk={defaultToken !== undefined} /> <KycBanner /> <NotificationCard notification={globalNotification} /> @@ -308,7 +317,7 @@ export function InstanceRoutes({ * Update instance page */} <Route - path={InstancePaths.update} + path={InstancePaths.server} component={InstanceUpdatePage} onBack={() => { route(`/`); @@ -322,13 +331,26 @@ export function InstanceRoutes({ onLoadError={ServerErrorRedirectTo(InstancePaths.error)} /> {/** + * Update instance page + */} + <Route + path={InstancePaths.token} + component={TokenPage} + onChange={() => { + route(`/`); + }} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.error)} + /> + {/** * Product pages */} <Route path={InstancePaths.product_list} component={ProductListPage} onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.update)} + onLoadError={ServerErrorRedirectTo(InstancePaths.server)} onCreate={() => { route(InstancePaths.product_new); }} @@ -361,6 +383,45 @@ export function InstanceRoutes({ }} /> {/** + * Bank pages + */} + <Route + path={InstancePaths.bank_list} + component={BankAccountListPage} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.server)} + onCreate={() => { + route(InstancePaths.bank_new); + }} + onSelect={(id: string) => { + route(InstancePaths.bank_update.replace(":bid", id)); + }} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + /> + <Route + path={InstancePaths.bank_update} + component={BankAccountUpdatePage} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.product_list)} + onConfirm={() => { + route(InstancePaths.bank_list); + }} + onBack={() => { + route(InstancePaths.bank_list); + }} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + /> + <Route + path={InstancePaths.bank_new} + component={BankAccountCreatePage} + onConfirm={() => { + route(InstancePaths.bank_list); + }} + onBack={() => { + route(InstancePaths.bank_list); + }} + /> + {/** * Order pages */} <Route @@ -373,7 +434,7 @@ export function InstanceRoutes({ route(InstancePaths.order_details.replace(":oid", id)); }} onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.update)} + onLoadError={ServerErrorRedirectTo(InstancePaths.server)} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} /> <Route @@ -389,8 +450,8 @@ export function InstanceRoutes({ <Route path={InstancePaths.order_new} component={OrderCreatePage} - onConfirm={() => { - route(InstancePaths.order_list); + onConfirm={(orderId: string) => { + route(InstancePaths.order_details.replace(":oid", orderId)); }} onBack={() => { route(InstancePaths.order_list); @@ -404,7 +465,7 @@ export function InstanceRoutes({ component={TransferListPage} onUnauthorized={LoginPageAccessDenied} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onLoadError={ServerErrorRedirectTo(InstancePaths.update)} + onLoadError={ServerErrorRedirectTo(InstancePaths.server)} onCreate={() => { route(InstancePaths.transfers_new); }} @@ -427,7 +488,7 @@ export function InstanceRoutes({ component={WebhookListPage} onUnauthorized={LoginPageAccessDenied} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onLoadError={ServerErrorRedirectTo(InstancePaths.update)} + onLoadError={ServerErrorRedirectTo(InstancePaths.server)} onCreate={() => { route(InstancePaths.webhooks_new); }} @@ -459,6 +520,45 @@ export function InstanceRoutes({ }} /> {/** + * Validator pages + */} + <Route + path={InstancePaths.validators_list} + component={ValidatorListPage} + onUnauthorized={LoginPageAccessDenied} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onLoadError={ServerErrorRedirectTo(InstancePaths.server)} + onCreate={() => { + route(InstancePaths.validators_new); + }} + onSelect={(id: string) => { + route(InstancePaths.validators_update.replace(":vid", id)); + }} + /> + <Route + path={InstancePaths.validators_update} + component={ValidatorUpdatePage} + onConfirm={() => { + route(InstancePaths.validators_list); + }} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.validators_list)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onBack={() => { + route(InstancePaths.validators_list); + }} + /> + <Route + path={InstancePaths.validators_new} + component={ValidatorCreatePage} + onConfirm={() => { + route(InstancePaths.validators_list); + }} + onBack={() => { + route(InstancePaths.validators_list); + }} + /> + {/** * Templates pages */} <Route @@ -466,7 +566,7 @@ export function InstanceRoutes({ component={TemplateListPage} onUnauthorized={LoginPageAccessDenied} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onLoadError={ServerErrorRedirectTo(InstancePaths.update)} + onLoadError={ServerErrorRedirectTo(InstancePaths.server)} onCreate={() => { route(InstancePaths.templates_new); }} @@ -535,7 +635,7 @@ export function InstanceRoutes({ component={ReservesListPage} onUnauthorized={LoginPageAccessDenied} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onLoadError={ServerErrorRedirectTo(InstancePaths.update)} + onLoadError={ServerErrorRedirectTo(InstancePaths.server)} onSelect={(id: string) => { route(InstancePaths.reserves_details.replace(":rid", id)); }} @@ -590,7 +690,7 @@ function AdminInstanceUpdatePage({ const { updateLoginStatus: changeBackend } = useBackendContext(); const updateLoginStatus = (url: string, token?: string): void => { changeBackend(url); - if (token) changeToken(token); + changeToken(token); }; const value = useMemo( () => ({ id, token, admin: true, changeToken }), @@ -607,20 +707,20 @@ function AdminInstanceUpdatePage({ 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 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 || + 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, - }; + ? error.payload.detail + : undefined, + type: "ERROR" as const, + }; return ( <Fragment> <NotificationCard notification={notif} /> @@ -650,7 +750,8 @@ function AdminInstanceUpdatePage({ function KycBanner(): VNode { const kycStatus = useInstanceKYCDetails(); const { i18n } = useTranslationContext(); - const today = format(new Date(), "yyyy-MM-dd"); + const [settings] = useSettings(); + const today = format(new Date(), dateFormatForSettings(settings)); const [lastHide, setLastHide] = useLocalStorage("kyc-last-hide"); const hasBeenHidden = today === lastHide; const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; diff --git a/packages/merchant-backoffice-ui/src/components/exception/login.tsx b/packages/merchant-backoffice-ui/src/components/exception/login.tsx index f2f94a7c5..4fa440fc7 100644 --- a/packages/merchant-backoffice-ui/src/components/exception/login.tsx +++ b/packages/merchant-backoffice-ui/src/components/exception/login.tsx @@ -93,7 +93,7 @@ export function LoginModal({ onConfirm, withMessage }: Props): VNode { <input class="input" type="password" - placeholder={"set new access token"} + placeholder={"current access token"} name="token" onKeyPress={(e) => e.keyCode === 13 @@ -186,7 +186,7 @@ export function LoginModal({ onConfirm, withMessage }: Props): VNode { <input class="input" type="password" - placeholder={"set new access token"} + placeholder={"current access token"} name="token" onKeyPress={(e) => e.keyCode === 13 diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx index 1f41c3564..a398629dc 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx @@ -20,16 +20,18 @@ */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; -import { h, VNode } from "preact"; +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>({ @@ -41,9 +43,11 @@ export function InputDate<T>({ 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); @@ -51,14 +55,14 @@ export function InputDate<T>({ if (!value) { strValue = withTimestampSupport ? "unknown" : ""; } else if (value instanceof Date) { - strValue = format(value, "yyyy/MM/dd"); + strValue = format(value, dateFormatForSettings(settings)); } else if (value.t_s) { strValue = value.t_s === "never" ? withTimestampSupport ? "never" : "" - : format(new Date(value.t_s * 1000), "yyyy/MM/dd"); + : format(new Date(value.t_s * 1000), dateFormatForSettings(settings)); } return ( @@ -142,6 +146,7 @@ export function InputDate<T>({ </button> </span> )} + {side} </div> <DatePicker opened={opened} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx index 8d324660e..5cd69a0b3 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx @@ -18,9 +18,9 @@ * * @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 { useCallback, useState } from "preact/hooks"; import { COUNTRY_TABLE } from "../../utils/constants.js"; import { undefinedIfEmpty } from "../../utils/table.js"; import { FormErrors, FormProvider } from "./FormProvider.js"; @@ -28,23 +28,23 @@ import { Input } from "./Input.js"; import { InputGroup } from "./InputGroup.js"; import { InputSelector } from "./InputSelector.js"; import { InputProps, useField } from "./useField.js"; -import { InputWithAddon } from "./InputWithAddon.js"; -import { MerchantBackend } from "../../declaration.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; + path1?: string; // path2 if the second field to be used, optional path2?: string; - // options of the payto uri - options: { + // params of the payto uri + params: { "receiver-name"?: string; sender?: string; message?: string; @@ -52,13 +52,6 @@ type Entity = { instruction?: string; [name: string]: string | undefined; }; - auth: { - type: "unset" | "basic" | "none"; - url?: string; - username?: string; - password?: string; - repeat?: string; - }; }; function isEthereumAddress(address: string) { @@ -171,14 +164,10 @@ const targets = [ "bitcoin", "ethereum", ]; -const accountAuthType = ["none", "basic"]; const noTargetValue = targets[0]; -const defaultTarget: Partial<Entity> = { +const defaultTarget: Entity = { target: noTargetValue, - options: {}, - auth: { - type: "unset" as const, - }, + params: {}, }; export function InputPaytoForm<T>({ @@ -187,110 +176,91 @@ export function InputPaytoForm<T>({ label, tooltip, }: Props<keyof T>): VNode { - const { value: paytos, onChange, required } = useField<T>(name); - - const [value, valueHandler] = useState<Partial<Entity>>(defaultTarget); + const { value: initialValueStr, onChange } = useField<T>(name); - let payToPath; - if (value.target === "iban" && value.path1) { - payToPath = `/${value.path1.toUpperCase()}`; - } else if (value.path1) { - if (value.path2) { - payToPath = `/${value.path1}/${value.path2}`; - } else { - payToPath = `/${value.path1}`; - } + 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 { i18n } = useTranslationContext(); + const [value, setValue] = useState<Partial<Entity>>(initial) - const ops = value.options ?? {}; - const url = tryUrl(`payto://${value.target}${payToPath}`); - if (url) { - Object.keys(ops).forEach((opt_key) => { - const opt_value = ops[opt_key]; - if (opt_value) url.searchParams.set(opt_key, opt_value); - }); - } - const paytoURL = !url ? "" : url.href; + const { i18n } = useTranslationContext(); const errors: FormErrors<Entity> = { target: - value.target === noTargetValue && !paytos.length + 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, + ? 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, - options: undefinedIfEmpty({ - "receiver-name": !value.options?.["receiver-name"] + params: undefinedIfEmpty({ + "receiver-name": !value.params?.["receiver-name"] ? i18n.str`required` : undefined, }), - auth: !value.auth - ? undefined - : undefinedIfEmpty({ - username: - value.auth.type === "basic" && !value.auth.username - ? i18n.str`required` - : undefined, - password: - value.auth.type === "basic" && !value.auth.password - ? i18n.str`required` - : undefined, - repeat: - value.auth.type === "basic" && !value.auth.repeat - ? i18n.str`required` - : value.auth.repeat !== value.auth.password - ? i18n.str`is not the same` - : undefined, - }), }; const hasErrors = Object.keys(errors).some( (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.Instances.MerchantBankAccount[] = paytos; - const alreadyExists = - accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1; - if (!alreadyExists) { - const newValue: MerchantBackend.Instances.MerchantBankAccount = { - 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, ...accounts] as any); - } - valueHandler(defaultTarget); - }, [value]); + // 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 ( @@ -299,11 +269,11 @@ export function InputPaytoForm<T>({ name="tax" errors={errors} object={value} - valueHandler={valueHandler} + valueHandler={setValue} > <InputSelector<Entity> name="target" - label={i18n.str`Target type`} + label={i18n.str`Account type`} tooltip={i18n.str`Method to use for wire transfer`} values={targets} toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)} @@ -400,150 +370,15 @@ export function InputPaytoForm<T>({ {value.target !== noTargetValue && ( <Fragment> <Input - name="options.receiver-name" + name="params.receiver-name" label={i18n.str`Name`} tooltip={i18n.str`Bank account owner's name.`} /> - <InputWithAddon - name="auth.url" - label={i18n.str`Account info URL`} - help="https://bank.com" - expand - tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`} - /> - <InputSelector - name="auth.type" - label={i18n.str`Auth type`} - tooltip={i18n.str`Choose the authentication type for the account info URL`} - values={accountAuthType} - toStr={(str) => { - // if (str === "unset") { - // return "Without change"; - // } - if (str === "none") return "Without authentication"; - return "Username and password"; - }} - /> - {value.auth?.type === "basic" ? ( - <Fragment> - <Input - name="auth.username" - label={i18n.str`Username`} - tooltip={i18n.str`Username to access the account information.`} - /> - <Input - name="auth.password" - inputType="password" - label={i18n.str`Password`} - tooltip={i18n.str`Password to access the account information.`} - /> - <Input - name="auth.repeat" - inputType="password" - label={i18n.str`Repeat password`} - /> - </Fragment> - ) : undefined} - - {/* <InputWithAddon - name="options.credit_credentials" - label={i18n.str`Account info`} - inputType={showKey ? "text" : "password"} - help="From where the merchant can download information about incoming wire transfers to this account" - expand - tooltip={i18n.str`Useful to validate the purchase`} - fromStr={(v) => v.toUpperCase()} - addonAfter={ - <span class="icon"> - {showKey ? ( - <i class="mdi mdi-eye" /> - ) : ( - <i class="mdi mdi-eye-off" /> - )} - </span> - } - side={ - <span style={{ display: "flex" }}> - <button - data-tooltip={ - showKey - ? i18n.str`show secret key` - : i18n.str`hide secret key` - } - class="button is-info mr-3" - onClick={(e) => { - setShowKey(!showKey); - }} - > - {showKey ? ( - <i18n.Translate>hide</i18n.Translate> - ) : ( - <i18n.Translate>show</i18n.Translate> - )} - </button> - </span> - } - /> */} </Fragment> )} - {/** - * Show the values in the list - */} - <div class="field is-horizontal"> - <div class="field-label is-normal" /> - <div class="field-body" style={{ display: "block" }}> - {paytos.map( - (v: MerchantBackend.Instances.MerchantBankAccount, i: number) => ( - <div - key={i} - class="tags has-addons mt-3 mb-0 mr-3" - style={{ flexWrap: "nowrap" }} - > - <span - class="tag is-medium is-info mb-0" - style={{ maxWidth: "90%" }} - > - {v.payto_uri} - </span> - <a - class="tag is-medium is-danger is-delete mb-0" - onClick={() => { - onChange(paytos.filter((f: any) => f !== v) as any); - }} - /> - </div> - ), - )} - {!paytos.length && i18n.str`No accounts yet.`} - {required && ( - <span class="icon has-text-danger is-right"> - <i class="mdi mdi-alert" /> - </span> - )} - </div> - </div> - {value.target !== noTargetValue && ( - <div class="buttons is-right mt-5"> - <button - class="button is-info" - data-tooltip={i18n.str`add tax to the tax list`} - disabled={hasErrors} - onClick={submit} - > - <i18n.Translate>Add</i18n.Translate> - </button> - </div> - )} </FormProvider> </InputGroup> ); } -function tryUrl(s: string): URL | undefined { - try { - return new URL(s); - } catch (e) { - return undefined; - } -} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSearchProduct.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx index 1c1fcb907..be5800d14 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputSearchProduct.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx @@ -22,32 +22,41 @@ 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 { MerchantBackend, WithId } from "../../declaration.js"; import { FormErrors, FormProvider } from "./FormProvider.js"; import { InputWithAddon } from "./InputWithAddon.js"; +import { TranslatedString } from "@gnu-taler/taler-util"; -type Entity = MerchantBackend.Products.ProductDetail & WithId; +type Entity = { + id: string, + description: string; + image?: string; + extra?: string; +}; -export interface Props { - selected?: Entity; - onChange: (p?: Entity) => void; - products: (MerchantBackend.Products.ProductDetail & WithId)[]; +export interface Props<T extends Entity> { + selected?: T; + onChange: (p?: T) => void; + label: TranslatedString; + list: T[]; + withImage?: boolean; } -interface ProductSearch { +interface Search { name: string; } -export function InputSearchProduct({ +export function InputSearchOnList<T extends Entity>({ selected, onChange, - products, -}: Props): VNode { - const [prodForm, setProdName] = useState<Partial<ProductSearch>>({ + label, + list, + withImage, +}: Props<T>): VNode { + const [nameForm, setNameForm] = useState<Partial<Search>>({ name: "", }); - const errors: FormErrors<ProductSearch> = { + const errors: FormErrors<Search> = { name: undefined, }; const { i18n } = useTranslationContext(); @@ -55,15 +64,17 @@ export function InputSearchProduct({ if (selected) { return ( <article class="media"> - <figure class="media-left"> - <p class="image is-128x128"> - <img src={selected.image ? selected.image : emptyImage} /> - </p> - </figure> + {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>Product id</i18n.Translate>: <b>{selected.id}</b> + <i18n.Translate>ID</i18n.Translate>: <b>{selected.id}</b> </p> <p> <i18n.Translate>Description</i18n.Translate>:{" "} @@ -84,15 +95,15 @@ export function InputSearchProduct({ } return ( - <FormProvider<ProductSearch> + <FormProvider<Search> errors={errors} - object={prodForm} - valueHandler={setProdName} + object={nameForm} + valueHandler={setNameForm} > - <InputWithAddon<ProductSearch> + <InputWithAddon<Search> name="name" - label={i18n.str`Product`} - tooltip={i18n.str`search products by it's description or id`} + label={label} + tooltip={i18n.str`enter description or id`} addonAfter={ <span class="icon"> <i class="mdi mdi-magnify" /> @@ -100,13 +111,14 @@ export function InputSearchProduct({ } > <div> - <ProductList - name={prodForm.name} - list={products} + <DropdownList + name={nameForm.name} + list={list} onSelect={(p) => { - setProdName({ name: "" }); + setNameForm({ name: "" }); onChange(p); }} + withImage={!!withImage} /> </div> </InputWithAddon> @@ -114,13 +126,14 @@ export function InputSearchProduct({ ); } -interface ProductListProps { +interface DropdownListProps<T extends Entity> { name?: string; - onSelect: (p: MerchantBackend.Products.ProductDetail & WithId) => void; - list: (MerchantBackend.Products.ProductDetail & WithId)[]; + onSelect: (p: T) => void; + list: T[]; + withImage: boolean; } -function ProductList({ name, onSelect, list }: ProductListProps) { +function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) { const { i18n } = useTranslationContext(); if (!name) { /* FIXME @@ -149,7 +162,7 @@ function ProductList({ name, onSelect, list }: ProductListProps) { {!filtered.length ? ( <div class="dropdown-item"> <i18n.Translate> - no products found with that description + no match found with that description or id </i18n.Translate> </div> ) : ( @@ -161,18 +174,20 @@ function ProductList({ name, onSelect, list }: ProductListProps) { style={{ cursor: "pointer" }} > <article class="media"> - <div class="media-left"> - <div class="image" style={{ minWidth: 64 }}> - <img - src={p.image ? p.image : emptyImage} - style={{ width: 64, height: 64 }} - /> + {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> + } <div class="media-content"> <div class="content"> <p> - <strong>{p.id}</strong> <small>{p.price}</small> + <strong>{p.id}</strong> {p.extra !== undefined ? <small>{p.extra}</small> : undefined} <br /> {p.description} </p> diff --git a/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx b/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx index 61ddf3c84..f95dfcd05 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx @@ -56,7 +56,7 @@ export function InputToggle<T>({ return ( <div class="field is-horizontal"> <div class="field-label is-normal"> - <label class="label" style={{ width: 200 }}> + <label class="label" > {label} {tooltip && ( <span class="icon has-tooltip-right" data-tooltip={tooltip}> @@ -65,7 +65,7 @@ export function InputToggle<T>({ )} </label> </div> - <div class="field-body is-flex-grow-1"> + <div class="field-body is-flex-grow-3"> <div class="field"> <p class={expand ? "control is-expanded" : "control"}> <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}> diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx index 24380ce98..b75dc83b3 100644 --- a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx +++ b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -24,14 +24,13 @@ 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 { InputCurrency } from "../form/InputCurrency.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 { InputPaytoForm } from "../form/InputPaytoForm.js"; -import { InputWithAddon } from "../form/InputWithAddon.js"; import { InputSelector } from "../form/InputSelector.js"; +import { InputToggle } from "../form/InputToggle.js"; +import { InputWithAddon } from "../form/InputWithAddon.js"; export function DefaultInstanceFormFields({ readonlyId, @@ -85,28 +84,10 @@ export function DefaultInstanceFormFields({ tooltip={i18n.str`Logo image.`} /> - <InputPaytoForm<Entity> - name="accounts" - label={i18n.str`Bank account`} - tooltip={i18n.str`URI specifying bank account for crediting revenue.`} - /> - - <InputCurrency<Entity> - name="default_max_deposit_fee" - label={i18n.str`Default max deposit fee`} - tooltip={i18n.str`Maximum deposit fees this merchant is willing to pay per order by default.`} - /> - - <InputCurrency<Entity> - name="default_max_wire_fee" - label={i18n.str`Default max wire fee`} - tooltip={i18n.str`Maximum wire fees this merchant is willing to pay per wire transfer by default.`} - /> - - <Input<Entity> - name="default_wire_fee_amortization" - label={i18n.str`Default wire fee amortization`} - tooltip={i18n.str`Number of orders excess wire transfer fees will be divided by to compute per order surcharge.`} + <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 diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx index f3cf80b92..be2f8dde5 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -25,6 +25,7 @@ import { useBackendContext } from "../../context/backend.js"; import { useConfigContext } from "../../context/config.js"; import { useInstanceKYCDetails } from "../../hooks/instance.js"; import { LangSelector } from "./LangSelector.js"; +import { useCredentialsChecker } from "../../hooks/backend.js"; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; @@ -36,6 +37,7 @@ interface Props { instance: string; admin?: boolean; mimic?: boolean; + isPasswordOk: boolean; } export function Sidebar({ @@ -45,6 +47,7 @@ export function Sidebar({ onLogout, admin, mimic, + isPasswordOk }: Props): VNode { const config = useConfigContext(); const backend = useBackendContext(); @@ -53,7 +56,7 @@ export function Sidebar({ const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; return ( - <aside class="aside is-placed-left is-expanded"> + <aside class="aside is-placed-left is-expanded" style={{ overflowY: "scroll" }}> {mobile && ( <div class="footer" @@ -78,10 +81,10 @@ export function Sidebar({ </div> </div> <div class="menu is-menu-main"> - {instance ? ( + {isPasswordOk && instance ? ( <Fragment> <ul class="menu-list"> - <li> + <li> <a href={"/orders"} class="has-icon"> <span class="icon"> <i class="mdi mdi-cash-register" /> @@ -104,7 +107,7 @@ export function Sidebar({ <li> <a href={"/transfers"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-bank" /> + <i class="mdi mdi-arrow-left-right" /> </span> <span class="menu-item-label"> <i18n.Translate>Transfers</i18n.Translate> @@ -137,12 +140,22 @@ export function Sidebar({ </p> <ul class="menu-list"> <li> - <a href={"/update"} class="has-icon"> + <a href={"/bank"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-square-edit-outline" /> + <i class="mdi mdi-bank" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Bank account</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/validators"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-lock" /> </span> <span class="menu-item-label"> - <i18n.Translate>Account</i18n.Translate> + <i18n.Translate>Validators</i18n.Translate> </span> </a> </li> @@ -164,6 +177,26 @@ export function Sidebar({ </span> </a> </li> + <li> + <a href={"/server"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-square-edit-outline" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Server</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/token"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-security" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Access token</i18n.Translate> + </span> + </a> + </li> </ul> </Fragment> ) : undefined} @@ -174,12 +207,12 @@ export function Sidebar({ <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>Settings</i18n.Translate> + <i18n.Translate>Interface</i18n.Translate> </span> </a> </li> @@ -211,7 +244,7 @@ export function Sidebar({ </span> </div> </li> - {admin && !mimic && ( + {isPasswordOk && admin && !mimic && ( <Fragment> <p class="menu-label"> <i18n.Translate>Instances</i18n.Translate> @@ -238,19 +271,21 @@ export function Sidebar({ </li> </Fragment> )} - <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> + {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> + } </ul> </div> </aside> diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx index cdbae4ae0..cb318906f 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/index.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/index.tsx @@ -24,7 +24,7 @@ import { Sidebar } from "./SideBar.js"; function getInstanceTitle(path: string, id: string): string { switch (path) { - case InstancePaths.update: + case InstancePaths.server: return `${id}: Settings`; case InstancePaths.order_list: return `${id}: Orders`; @@ -50,6 +50,12 @@ function getInstanceTitle(path: string, id: string): string { return `${id}: New webhook`; case InstancePaths.webhooks_update: return `${id}: Update webhook`; + case InstancePaths.validators_list: + return `${id}: Validators`; + case InstancePaths.validators_new: + return `${id}: New validator`; + case InstancePaths.validators_update: + return `${id}: Update validators`; case InstancePaths.templates_new: return `${id}: New template`; case InstancePaths.templates_update: @@ -58,6 +64,10 @@ function getInstanceTitle(path: string, id: string): string { return `${id}: Templates`; case InstancePaths.templates_use: return `${id}: Use template`; + case InstancePaths.settings: + return `${id}: Interface`; + case InstancePaths.settings: + return `${id}: Interface`; default: return ""; } @@ -77,6 +87,7 @@ interface MenuProps { onLogout?: () => void; onShowSettings: () => void; setInstanceName: (s: string) => void; + isPasswordOk: boolean; } function WithTitle({ @@ -100,14 +111,15 @@ export function Menu({ path, admin, setInstanceName, + isPasswordOk }: MenuProps): VNode { const [mobileOpen, setMobileOpen] = useState(false); const titleWithSubtitle = title ? title : !admin - ? getInstanceTitle(path, instance) - : getAdminTitle(path, instance); + ? getInstanceTitle(path, instance) + : getAdminTitle(path, instance); const adminInstance = instance === "default"; const mimic = admin && !adminInstance; return ( @@ -129,14 +141,15 @@ export function Menu({ mimic={mimic} instance={instance} mobile={mobileOpen} + isPasswordOk={isPasswordOk} /> )} {mimic && ( <nav class="level" style={{ zIndex: 100, - position:"fixed", - width:"50%", + position: "fixed", + width: "50%", marginLeft: "20%" }}> <div class="level-item has-text-centered has-background-warning"> @@ -161,8 +174,9 @@ export function Menu({ interface NotYetReadyAppMenuProps { title: string; - onLogout?: () => void; onShowSettings: () => void; + onLogout?: () => void; + isPasswordOk: boolean; } interface NotifProps { @@ -181,8 +195,8 @@ export function NotificationCard({ n.type === "ERROR" ? "message is-danger" : n.type === "WARN" - ? "message is-warning" - : "message is-info" + ? "message is-warning" + : "message is-info" } > <div class="message-header"> @@ -201,10 +215,36 @@ export function NotificationCard({ ); } +interface NotConnectedAppMenuProps { + title: string; +} +export function NotConnectedAppMenu({ + title, +}: NotConnectedAppMenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); + + useEffect(() => { + document.title = `Taler Backoffice: ${title}`; + }, [title]); + + return ( + <div + class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={title} + /> + </div> + ); +} + export function NotYetReadyAppMenu({ onLogout, onShowSettings, title, + isPasswordOk }: NotYetReadyAppMenuProps): VNode { const [mobileOpen, setMobileOpen] = useState(false); @@ -222,7 +262,7 @@ export function NotYetReadyAppMenu({ title={title} /> {onLogout && ( - <Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} /> + <Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} isPasswordOk={isPasswordOk} /> )} </div> ); diff --git a/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx index b2ec4dd11..377d9c1ba 100644 --- a/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx @@ -20,7 +20,7 @@ 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 { InputSearchProduct } from "../form/InputSearchProduct.js"; +import { InputSearchOnList } from "../form/InputSearchOnList.js"; type Form = { product: MerchantBackend.Products.ProductDetail & WithId; @@ -95,10 +95,12 @@ export function InventoryProductForm({ return ( <FormProvider<Form> errors={errors} object={state} valueHandler={setState}> - <InputSearchProduct + <InputSearchOnList + label={i18n.str`Search product`} selected={state.product} onChange={(p) => setState((v) => ({ ...v, product: p }))} - products={inventory} + list={inventory} + withImage /> {state.product && ( <div class="columns mt-5"> diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx index 7956a9ea5..4cd90aa45 100644 --- a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx @@ -58,12 +58,12 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { !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, - }, + 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> = {}; @@ -148,15 +148,17 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { name="minimum_age" label={i18n.str`Age restricted`} tooltip={i18n.str`is this product restricted for customer below certain age?`} + help={i18n.str`can be overriden by the order configuration`} /> <Input<Entity> name="unit" - label={i18n.str`Unit`} + label={i18n.str`Unit name`} tooltip={i18n.str`unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers`} + help={i18n.str`exajmple: kg, items or liters`} /> <InputCurrency<Entity> name="price" - label={i18n.str`Price`} + label={i18n.str`Price per unit`} tooltip={i18n.str`sale price for customers, including taxes, for above units of the product`} /> <InputStock diff --git a/packages/merchant-backoffice-ui/src/context/backend.ts b/packages/merchant-backoffice-ui/src/context/backend.ts index f7f8afea6..43e9e4d27 100644 --- a/packages/merchant-backoffice-ui/src/context/backend.ts +++ b/packages/merchant-backoffice-ui/src/context/backend.ts @@ -28,8 +28,8 @@ interface BackendContextType { token?: string; triedToLog: boolean; resetBackend: () => void; - clearAllTokens: () => void; - addTokenCleaner: (c: () => void) => void; + // clearAllTokens: () => void; + // addTokenCleaner: (c: () => void) => void; updateLoginStatus: (url: string, token?: string) => void; updateToken: (token?: string) => void; } @@ -39,8 +39,8 @@ const BackendContext = createContext<BackendContextType>({ token: undefined, triedToLog: false, resetBackend: () => null, - clearAllTokens: () => null, - addTokenCleaner: () => null, + // clearAllTokens: () => null, + // addTokenCleaner: () => null, updateLoginStatus: () => null, updateToken: () => null, }); @@ -56,30 +56,30 @@ function useBackendContextState( _updateToken(t); }; - const tokenCleaner = useCallback(() => { - updateToken(undefined); - }, []); - const [cleaners, setCleaners] = useState([tokenCleaner]); - const addTokenCleaner = (c: () => void) => setCleaners((cs) => [...cs, c]); - const addTokenCleanerMemo = useCallback( - (c: () => void) => { - addTokenCleaner(c); - }, - [tokenCleaner], - ); + // const tokenCleaner = useCallback(() => { + // updateToken(undefined); + // }, []); + // const [cleaners, setCleaners] = useState([tokenCleaner]); + // const addTokenCleaner = (c: () => void) => setCleaners((cs) => [...cs, c]); + // const addTokenCleanerMemo = useCallback( + // (c: () => void) => { + // addTokenCleaner(c); + // }, + // [tokenCleaner], + // ); - const clearAllTokens = () => { - cleaners.forEach((c) => c()); - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (k && /^backend-token/.test(k)) localStorage.removeItem(k); - } - resetBackend(); - }; + // const clearAllTokens = () => { + // cleaners.forEach((c) => c()); + // for (let i = 0; i < localStorage.length; i++) { + // const k = localStorage.key(i); + // if (k && /^backend-token/.test(k)) localStorage.removeItem(k); + // } + // resetBackend(); + // }; const updateLoginStatus = (url: string, token?: string) => { changeBackend(url); - if (token) updateToken(token); + updateToken(token); }; return { @@ -88,9 +88,9 @@ function useBackendContextState( triedToLog, updateLoginStatus, resetBackend, - clearAllTokens, + // clearAllTokens, updateToken, - addTokenCleaner: addTokenCleanerMemo, + // addTokenCleaner: addTokenCleanerMemo, }; } diff --git a/packages/merchant-backoffice-ui/src/declaration.d.ts b/packages/merchant-backoffice-ui/src/declaration.d.ts index db3122266..5ca9c1e09 100644 --- a/packages/merchant-backoffice-ui/src/declaration.d.ts +++ b/packages/merchant-backoffice-ui/src/declaration.d.ts @@ -25,6 +25,8 @@ type EddsaSignature = string; type WireTransferIdentifierRawP = string; type RelativeTime = Duration; type ImageDataUrl = string; +type MerchantUserType = "business" | "individual"; + export interface WithId { id: string; @@ -312,46 +314,8 @@ export namespace MerchantBackend { // header. token?: string; } - type FacadeCredentials = NoFacadeCredentials | BasicAuthFacadeCredentials; - - interface NoFacadeCredentials { - type: "none"; - } - - interface BasicAuthFacadeCredentials { - type: "basic"; - - // Username to use to authenticate - username: string; - - // Password to use to authenticate - password: string; - } - - interface MerchantBankAccount { - // The payto:// URI where the wallet will send coins. - payto_uri: string; - - // Optional base URL for a facade where the - // merchant backend can see incoming wire - // transfers to reconcile its accounting - // with that of the exchange. Used by - // taler-merchant-wirewatch. - credit_facade_url?: string; - - // Credentials for accessing the credit facade. - credit_facade_credentials?: FacadeCredentials; - } //POST /private/instances interface InstanceConfigurationMessage { - // Bank accounts of the merchant. A merchant may have - // multiple accounts, thus this is an array. Note that by - // removing accounts from this list the respective account is set to - // inactive and thus unavailable for new contracts, but preserved - // in the database as existing offers and contracts may still refer - // to it. - accounts: MerchantBankAccount[]; - // Name of the merchant instance to create (will become $INSTANCE). id: string; @@ -361,12 +325,16 @@ export namespace MerchantBackend { // 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?: string; + user_type?: MerchantUserType; + + // Merchant email for customer contact. + email?: string; + + // Merchant public website. + website?: string; - email: string; - website: string; - // An optional base64-encoded logo image - logo: ImageDataUrl; + // Merchant logo. + logo?: ImageDataUrl; // "Authentication" header required to authorize management access the instance. // Optional, if not given authentication will be disabled for @@ -381,17 +349,10 @@ export namespace MerchantBackend { // (to be put into contracts). jurisdiction: Location; - // Maximum wire fee this instance is willing to pay. - // Can be overridden by the frontend on a per-order basis. - default_max_wire_fee: Amount; - - // Default factor for wire fee amortization calculations. - // Can be overridden by the frontend on a per-order basis. - default_wire_fee_amortization: Integer; - - // Maximum deposit fee (sum over all coins) this instance is willing to pay. - // Can be overridden by the frontend on a per-order basis. - default_max_deposit_fee: Amount; + // 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 @@ -406,11 +367,6 @@ export namespace MerchantBackend { // PATCH /private/instances/$INSTANCE interface InstanceReconfigurationMessage { - // Bank accounts of the merchant. A merchant may have - // multiple accounts, thus this is an array. Note that removing - // URIs from this list deactivates the specified accounts - // (they will no longer be used for future contracts). - accounts: MerchantBankAccount[]; // Merchant name corresponding to this instance. name: string; @@ -418,7 +374,16 @@ export namespace MerchantBackend { // 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?: string; + user_type?: MerchantUserType; + + // Merchant email for customer contact. + email?: string; + + // Merchant public website. + website?: string; + + // Merchant logo. + logo?: ImageDataUrl; // The merchant's physical address (to be put into contracts). address: Location; @@ -427,17 +392,10 @@ export namespace MerchantBackend { // (to be put into contracts). jurisdiction: Location; - // Maximum wire fee this instance is willing to pay. - // Can be overridden by the frontend on a per-order basis. - default_max_wire_fee: Amount; - - // Default factor for wire fee amortization calculations. - // Can be overridden by the frontend on a per-order basis. - default_wire_fee_amortization: Integer; - - // Maximum deposit fee (sum over all coins) this instance is willing to pay. - // Can be overridden by the frontend on a per-order basis. - default_max_deposit_fee: Amount; + // 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 @@ -460,7 +418,14 @@ export namespace MerchantBackend { // Merchant name corresponding to this instance. name: string; - deleted?: boolean; + // Type of the user ("business" or "individual"). + user_type: MerchantUserType; + + // Merchant public website. + website?: string; + + // Merchant logo. + logo?: ImageDataUrl; // Merchant instance this response is about ($INSTANCE) id: string; @@ -472,8 +437,63 @@ export namespace MerchantBackend { // 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 + } + + namespace KYC { //GET /private/instances/$INSTANCE/kyc interface AccountKycRedirects { // Array of pending KYCs. @@ -513,56 +533,76 @@ export namespace MerchantBackend { exchange_http_status: number; } - //GET /private/instances/$INSTANCE - interface QueryInstancesResponse { - // The URI where the wallet will send coins. A merchant may have - // multiple accounts, thus this is an array. - accounts: MerchantAccount[]; + } - // Merchant name corresponding to this instance. - name: string; + namespace BankAccounts { - // Public key of the merchant/instance, in Crockford Base32 encoding. - merchant_pub: EddsaPublicKey; + interface AccountAddDetails { - // The merchant's physical address (to be put into contracts). - address: Location; + // payto:// URI of the account. + payto_uri: string; - // The jurisdiction under which the merchant conducts its business - // (to be put into contracts). - jurisdiction: Location; + // URL from where the merchant can download information + // about incoming wire transfers to this account. + credit_facade_url?: string; - // Maximum wire fee this instance is willing to pay. - // Can be overridden by the frontend on a per-order basis. - default_max_wire_fee: Amount; + // 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; - // Default factor for wire fee amortization calculations. - // Can be overridden by the frontend on a per-order basis. - default_wire_fee_amortization: Integer; + } - // Maximum deposit fee (sum over all coins) this instance is willing to pay. - // Can be overridden by the frontend on a per-order basis. - default_max_deposit_fee: Amount; + type FacadeCredentials = + | NoFacadeCredentials + | BasicAuthFacadeCredentials; - // 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; + interface NoFacadeCredentials { + type: "none"; + } - // If the frontend does NOT specify a payment deadline, how long should - // offers we make be valid by default? - default_pay_delay: RelativeTime; + interface BasicAuthFacadeCredentials { + type: "basic"; - // Authentication configuration. - // Does not contain the token when token auth is configured. - auth: { - method: "external" | "token"; - token?: string; - }; + // 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 MerchantAccount { + + interface AccountsSummaryResponse { + + // List of accounts that are known for the instance. + accounts: BankAccountEntry[]; + } + + interface BankAccountEntry { // payto:// URI of the account. payto_uri: string; @@ -587,7 +627,6 @@ export namespace MerchantBackend { active: boolean; } - // DELETE /private/instances/$INSTANCE } namespace Products { @@ -957,6 +996,10 @@ export namespace MerchantBackend { // 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; @@ -1031,9 +1074,9 @@ export namespace MerchantBackend { } } - namespace Tips { + namespace Rewards { // GET /private/reserves - interface TippingReserveStatus { + interface RewardReserveStatus { // Array of all known reserves (possibly empty!) reserves: ReserveStatusEntry[]; } @@ -1057,7 +1100,7 @@ export namespace MerchantBackend { // Amount picked up so far. pickup_amount: Amount; - // Amount approved for tips that exceeds the pickup_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) @@ -1068,7 +1111,7 @@ export namespace MerchantBackend { // Amount that the merchant promises to put into the reserve initial_balance: Amount; - // Exchange the merchant intends to use for tipping + // Exchange the merchant intends to use for reward exchange_url: string; // Desired wire method, for example "iban" or "x-taler-bank" @@ -1081,30 +1124,30 @@ export namespace MerchantBackend { // Wire accounts of the exchange where to transfer the funds. accounts: WireAccount[]; } - interface TipCreateRequest { - // Amount that the customer should be tipped + interface RewardCreateRequest { + // Amount that the customer should be reward amount: Amount; - // Justification for giving the tip + // Justification for giving the reward justification: string; - // URL that the user should be directed to after tipping, - // will be included in the tip_token. + // URL that the user should be directed to after rewarding, + // will be included in the reward_token. next_url: string; } - interface TipCreateConfirmation { - // Unique tip identifier for the tip that was created. - tip_id: HashCode; + interface RewardCreateConfirmation { + // Unique reward identifier for the reward that was created. + reward_id: HashCode; - // taler://tip URI for the tip - taler_tip_uri: string; + // taler://reward URI for the reward + taler_reward_uri: string; // URL that will directly trigger processing - // the tip when the browser is redirected to it - tip_status_url: string; + // the reward when the browser is redirected to it + reward_status_url: string; - // when does the tip expire - tip_expiration: Timestamp; + // when does the reward expire + reward_expiration: Timestamp; } interface ReserveDetail { @@ -1124,12 +1167,12 @@ export namespace MerchantBackend { // Amount picked up so far. pickup_amount: Amount; - // Amount approved for tips that exceeds the pickup_amount. + // Amount approved for rewards that exceeds the pickup_amount. committed_amount: Amount; - // Array of all tips created by this reserves (possibly empty!). + // Array of all rewards created by this reserves (possibly empty!). // Only present if asked for explicitly. - tips?: TipStatusEntry[]; + rewards?: RewardStatusEntry[]; // Is this reserve active (false if it was deleted but not purged)? active: boolean; @@ -1144,31 +1187,31 @@ export namespace MerchantBackend { exchange_url: string; } - interface TipStatusEntry { - // Unique identifier for the tip. - tip_id: HashCode; + interface RewardStatusEntry { + // Unique identifier for the reward. + reward_id: HashCode; - // Total amount of the tip that can be withdrawn. + // Total amount of the reward that can be withdrawn. total_amount: Amount; - // Human-readable reason for why the tip was granted. + // Human-readable reason for why the reward was granted. reason: string; } - interface TipDetails { - // Amount that we authorized for this tip. + 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 tip. + // Human-readable reason given when authorizing the reward. reason: string; - // Timestamp indicating when the tip is set to expire (may be in the past). + // Timestamp indicating when the reward is set to expire (may be in the past). expiration: Timestamp; - // Reserve public key from which the tip is funded. + // Reserve public key from which the reward is funded. reserve_pub: EddsaPublicKey; // Array showing the pickup operations of the wallet (possibly empty!). @@ -1239,6 +1282,63 @@ export namespace MerchantBackend { } } + namespace OTP { + interface OtpDeviceAddDetails { + // Device ID to use. + otp_device_id: string; + + // Human-readable description for the device. + otp_description: string; + + // A base64-encoded key + otp_key: string; + + // Algorithm for computing the POS confirmation. + otp_algorithm: Integer; + + // Counter for counter-based OTP devices. + otp_ctr?: Integer; + } + + interface OtpDevicePatchDetails { + // Human-readable description for the device. + otp_description: string; + + // A base64-encoded key + otp_key: string | undefined; + + // Algorithm for computing the POS confirmation. + otp_algorithm: Integer; + + // Counter for counter-based OTP devices. + otp_ctr?: Integer; + } + + interface OtpDeviceSummaryResponse { + // Array of devices that are present in our backend. + otp_devices: OtpDeviceEntry[]; + } + interface OtpDeviceEntry { + // Device identifier. + otp_device_id: string; + + // Human-readable description for the device. + device_description: string; + } + + interface OtpDeviceDetails { + // Human-readable description for the device. + device_description: string; + + // Algorithm for computing the POS confirmation. + otp_algorithm: Integer; + + // Counter for counter-based OTP devices. + otp_ctr?: Integer; + } + + + } namespace Template { interface TemplateAddDetails { // Template ID to use. @@ -1247,12 +1347,9 @@ export namespace MerchantBackend { // Human-readable description for the template. template_description: string; - // A base64-encoded key of the point-of-sale. + // OTP device ID. // This parameter is optional. - pos_key?: string; - - // Algorithm for computing the POS confirmation, 0 for none. - pos_algorithm?: number; + otp_id?: string; // Additional information in a separate template. template_contract: TemplateContractDetails; @@ -1276,12 +1373,9 @@ export namespace MerchantBackend { // Human-readable description for the template. template_description: string; - // A base64-encoded key of the point-of-sale. + // OTP device ID. // This parameter is optional. - pos_key?: string; - - // Algorithm for computing the POS confirmation, 0 for none. - pos_algorithm?: Integer; + otp_id?: string; // Additional information in a separate template. template_contract: TemplateContractDetails; @@ -1304,12 +1398,9 @@ export namespace MerchantBackend { // Human-readable description for the template. template_description: string; - // A base64-encoded key of the point-of-sale. + // OTP device ID. // This parameter is optional. - pos_key?: string; - - // Algorithm for computing the POS confirmation, 0 for none. - pos_algorithm?: Integer; + otp_id?: string; // Additional information in a separate template. template_contract: TemplateContractDetails; @@ -1424,21 +1515,6 @@ export namespace MerchantBackend { // Maximum total deposit fee accepted by the merchant for this contract max_fee: Amount; - // Maximum wire fee accepted by the merchant (customer share to be - // divided by the 'wire_fee_amortization' factor, and further reduced - // if deposit fees are below 'max_fee'). Default if missing is zero. - max_wire_fee: Amount; - - // Over how many customer transactions does the merchant expect to - // amortize wire fees on average? If the exchange's wire fee is - // above 'max_wire_fee', the difference is divided by this number - // to compute the expected customer's contribution to the wire fee. - // The customer's contribution may further be reduced by the difference - // between the 'max_fee' and the sum of the actual deposit fees. - // Optional, default value if missing is 1. 0 and negative values are - // invalid and also interpreted as 1. - wire_fee_amortization: number; - // List of products that are part of the purchase (see Product). products: Product[]; diff --git a/packages/merchant-backoffice-ui/src/hooks/backend.ts b/packages/merchant-backoffice-ui/src/hooks/backend.ts index 145a366f6..ecd34df6d 100644 --- a/packages/merchant-backoffice-ui/src/hooks/backend.ts +++ b/packages/merchant-backoffice-ui/src/hooks/backend.ts @@ -33,8 +33,9 @@ import { } from "@gnu-taler/web-util/browser"; import { useApiContext } from "@gnu-taler/web-util/browser"; + export function useMatchMutate(): ( - re: RegExp, + re?: RegExp, value?: unknown, ) => Promise<any> { const { cache, mutate } = useSWRConfig(); @@ -45,13 +46,19 @@ export function useMatchMutate(): ( ); } - return function matchRegexMutate(re: RegExp, value?: unknown) { - const allKeys = Array.from(cache.keys()); - const keys = allKeys.filter((key) => re.test(key)); - const mutations = keys.map((key) => { - return mutate(key, value, true); + 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, }); - return Promise.all(mutations); }; } @@ -106,32 +113,32 @@ interface useBackendInstanceRequestType { ) => Promise<HttpResponseOk<T>>; fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; reserveDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; - tipsDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; - multiFetcher: <T>(url: string[]) => Promise<HttpResponseOk<T>[]>; + rewardsDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; + multiFetcher: <T>(params: [url: string[]]) => Promise<HttpResponseOk<T>[]>; orderFetcher: <T>( - endpoint: string, - paid?: YesOrNo, - refunded?: YesOrNo, - wired?: YesOrNo, - searchDate?: Date, - delta?: number, + params: [endpoint: string, + paid?: YesOrNo, + refunded?: YesOrNo, + wired?: YesOrNo, + searchDate?: Date, + delta?: number,] ) => Promise<HttpResponseOk<T>>; transferFetcher: <T>( - endpoint: string, - payto_uri?: string, - verified?: string, - position?: string, - delta?: number, + params: [endpoint: string, + payto_uri?: string, + verified?: string, + position?: string, + delta?: number,] ) => Promise<HttpResponseOk<T>>; templateFetcher: <T>( - endpoint: string, - position?: string, - delta?: number, + params: [endpoint: string, + position?: string, + delta?: number] ) => Promise<HttpResponseOk<T>>; webhookFetcher: <T>( - endpoint: string, - position?: string, - delta?: number, + params: [endpoint: string, + position?: string, + delta?: number] ) => Promise<HttpResponseOk<T>>; } interface useBackendBaseRequestType { @@ -147,7 +154,7 @@ export function useCredentialsChecker() { const { request } = useApiContext(); //check against instance details endpoint //while merchant backend doesn't have a login endpoint - return async function testLogin( + async function testLogin( instance: string, token: string, ): Promise<{ @@ -167,6 +174,7 @@ export function useCredentialsChecker() { return { valid: false, cause: ErrorType.UNEXPECTED }; } }; + return testLogin } /** @@ -212,8 +220,9 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const multiFetcher = useCallback( function multiFetcherImpl<T>( - endpoints: string[], + args: [endpoints: string[]], ): Promise<HttpResponseOk<T>[]> { + const [endpoints] = args return Promise.all( endpoints.map((endpoint) => requestHandler<T>(baseUrl, endpoint, { token }), @@ -232,13 +241,14 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const orderFetcher = useCallback( function orderFetcherImpl<T>( - endpoint: string, - paid?: YesOrNo, - refunded?: YesOrNo, - wired?: YesOrNo, - searchDate?: Date, - delta?: number, + 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 ? (searchDate.getTime() / 1000) + 1 @@ -260,7 +270,7 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { ): Promise<HttpResponseOk<T>> { return requestHandler<T>(baseUrl, endpoint, { params: { - tips: "yes", + rewards: "yes", }, token, }); @@ -268,8 +278,8 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { [baseUrl, token], ); - const tipsDetailFetcher = useCallback( - function tipsDetailFetcherImpl<T>( + const rewardsDetailFetcher = useCallback( + function rewardsDetailFetcherImpl<T>( endpoint: string, ): Promise<HttpResponseOk<T>> { return requestHandler<T>(baseUrl, endpoint, { @@ -284,12 +294,13 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const transferFetcher = useCallback( function transferFetcherImpl<T>( - endpoint: string, - payto_uri?: string, - verified?: string, - position?: string, - delta?: number, + 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; @@ -305,10 +316,11 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const templateFetcher = useCallback( function templateFetcherImpl<T>( - endpoint: string, - position?: string, - delta?: number, + args: [endpoint: string, + position?: string, + delta?: number,] ): Promise<HttpResponseOk<T>> { + const [endpoint, position, delta] = args const params: any = {}; if (delta !== undefined) { params.limit = delta; @@ -322,10 +334,11 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const webhookFetcher = useCallback( function webhookFetcherImpl<T>( - endpoint: string, - position?: string, - delta?: number, + args: [endpoint: string, + position?: string, + delta?: number,] ): Promise<HttpResponseOk<T>> { + const [endpoint, position, delta] = args const params: any = {}; if (delta !== undefined) { params.limit = delta; @@ -343,7 +356,7 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { multiFetcher, orderFetcher, reserveDetailFetcher, - tipsDetailFetcher, + rewardsDetailFetcher, transferFetcher, templateFetcher, webhookFetcher, diff --git a/packages/merchant-backoffice-ui/src/hooks/bank.ts b/packages/merchant-backoffice-ui/src/hooks/bank.ts new file mode 100644 index 000000000..03b064646 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/hooks/bank.ts @@ -0,0 +1,217 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +// const MOCKED_ACCOUNTS: Record<string, MerchantBackend.BankAccounts.AccountAddDetails> = { +// "hwire1": { +// h_wire: "hwire1", +// payto_uri: "payto://fake/iban/123", +// salt: "qwe", +// }, +// "hwire2": { +// h_wire: "hwire2", +// payto_uri: "payto://fake/iban/123", +// salt: "qwe2", +// }, +// } + +export function useBankAccountAPI(): BankAccountAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendInstanceRequest(); + + const createBankAccount = async ( + data: MerchantBackend.BankAccounts.AccountAddDetails, + ): Promise<HttpResponseOk<void>> => { + // MOCKED_ACCOUNTS[data.h_wire] = data + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/accounts`, { + method: "POST", + data, + }); + await mutateAll(/.*private\/accounts.*/); + return res; + }; + + const updateBankAccount = async ( + h_wire: string, + data: MerchantBackend.BankAccounts.AccountPatchDetails, + ): Promise<HttpResponseOk<void>> => { + // MOCKED_ACCOUNTS[h_wire].credit_facade_credentials = data.credit_facade_credentials + // MOCKED_ACCOUNTS[h_wire].credit_facade_url = data.credit_facade_url + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/accounts/${h_wire}`, { + method: "PATCH", + data, + }); + await mutateAll(/.*private\/accounts.*/); + return res; + }; + + const deleteBankAccount = async ( + h_wire: string, + ): Promise<HttpResponseOk<void>> => { + // delete MOCKED_ACCOUNTS[h_wire] + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/accounts/${h_wire}`, { + method: "DELETE", + }); + await mutateAll(/.*private\/accounts.*/); + return res; + }; + + return { + createBankAccount, + updateBankAccount, + deleteBankAccount, + }; +} + +export interface BankAccountAPI { + createBankAccount: ( + data: MerchantBackend.BankAccounts.AccountAddDetails, + ) => Promise<HttpResponseOk<void>>; + updateBankAccount: ( + id: string, + data: MerchantBackend.BankAccounts.AccountPatchDetails, + ) => Promise<HttpResponseOk<void>>; + deleteBankAccount: (id: string) => Promise<HttpResponseOk<void>>; +} + +export interface InstanceBankAccountFilter { +} + +export function useInstanceBankAccounts( + args?: InstanceBankAccountFilter, + updatePosition?: (id: string) => void, +): HttpResponsePaginated< + MerchantBackend.BankAccounts.AccountsSummaryResponse, + MerchantBackend.ErrorDetail +> { + // return { + // ok: true, + // loadMore() { }, + // loadMorePrev() { }, + // data: { + // accounts: Object.values(MOCKED_ACCOUNTS).map(e => ({ + // ...e, + // active: true, + // })) + // } + // } + const { fetcher } = useBackendInstanceRequest(); + + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.BankAccounts.AccountsSummaryResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/accounts`], fetcher); + + const [lastAfter, setLastAfter] = useState< + HttpResponse< + MerchantBackend.BankAccounts.AccountsSummaryResponse, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + }, [afterData /*, beforeData*/]); + + if (afterError) return afterError.cause; + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = + afterData && afterData.data.accounts.length < totalAfter; + const isReachingStart = false; + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.accounts.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = `${afterData.data.accounts[afterData.data.accounts.length - 1] + .h_wire + }`; + if (from && updatePosition) updatePosition(from); + } + }, + loadMorePrev: () => { + }, + }; + + const accounts = !afterData ? [] : (afterData || lastAfter).data.accounts; + if (loadingAfter /* || loadingBefore */) + return { loading: true, data: { accounts } }; + if (/*beforeData &&*/ afterData) { + return { ok: true, data: { accounts }, ...pagination }; + } + return { loading: true }; +} + +export function useBankAccountDetails( + h_wire: string, +): HttpResponse< + MerchantBackend.BankAccounts.BankAccountEntry, + MerchantBackend.ErrorDetail +> { + // return { + // ok: true, + // data: { + // ...MOCKED_ACCOUNTS[h_wire], + // active: true, + // } + // } + const { fetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.BankAccounts.BankAccountEntry>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/accounts/${h_wire}`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) { + return data; + } + if (error) return error.cause; + return { loading: true }; +} diff --git a/packages/merchant-backoffice-ui/src/hooks/index.ts b/packages/merchant-backoffice-ui/src/hooks/index.ts index b77b9dea8..79b22304a 100644 --- a/packages/merchant-backoffice-ui/src/hooks/index.ts +++ b/packages/merchant-backoffice-ui/src/hooks/index.ts @@ -19,9 +19,10 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { StateUpdater, useCallback, useState } from "preact/hooks"; +import { StateUpdater, useCallback, useEffect, useState } from "preact/hooks"; import { ValueOrFunction } from "../utils/types.js"; import { useMemoryStorage } from "@gnu-taler/web-util/browser"; +import { useMatchMutate } from "./backend.js"; const calculateRootPath = () => { const rootPath = @@ -56,8 +57,22 @@ export function useBackendDefaultToken( ): [string | undefined, ((d: string | undefined) => void)] { // uncomment for testing initialValue = "secret-token:secret" as string | undefined - const { update, value } = useMemoryStorage(`backend-token`, initialValue) - return [value, update]; + const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token`, initialValue) + const clearCache = useMatchMutate() + useEffect(() => { + clearCache() + }, [token]) + + function updateToken( + value: (string | undefined) + ): void { + if (value === undefined) { + reset() + } else { + setToken(value) + } + } + return [token, updateToken]; } export function useBackendInstanceToken( @@ -73,14 +88,12 @@ export function useBackendInstanceToken( function updateToken( value: (string | undefined) ): void { - console.log("seeting token", value) if (value === undefined) { reset() } else { setToken(value) } } - console.log("token", token) return [token, updateToken]; } diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts index f78de85dd..d15b3f6d7 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts @@ -113,7 +113,7 @@ describe("instance api interaction with details", () => { name: "instance_name", auth: { method: "token", - token: "not-secret", + // token: "not-secret", }, } as MerchantBackend.Instances.QueryInstancesResponse, }); @@ -154,7 +154,7 @@ describe("instance api interaction with details", () => { name: "instance_name", auth: { method: "token", - token: "secret", + // token: "secret", }, } as MerchantBackend.Instances.QueryInstancesResponse, }); @@ -190,7 +190,7 @@ describe("instance api interaction with details", () => { name: "instance_name", auth: { method: "token", - token: "not-secret", + // token: "not-secret", }, } as MerchantBackend.Instances.QueryInstancesResponse, }); diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts index eae65d64c..32ed30c6f 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.ts @@ -198,6 +198,7 @@ export function useInstanceDetails(): HttpResponse< revalidateOnFocus: false, revalidateOnReconnect: false, refreshWhenOffline: false, + revalidateIfStale: false, errorRetryCount: 0, errorRetryInterval: 1, shouldRetryOnError: false, @@ -211,7 +212,7 @@ export function useInstanceDetails(): HttpResponse< type KYCStatus = | { type: "ok" } - | { type: "redirect"; status: MerchantBackend.Instances.AccountKycRedirects }; + | { type: "redirect"; status: MerchantBackend.KYC.AccountKycRedirects }; export function useInstanceKYCDetails(): HttpResponse< KYCStatus, @@ -220,7 +221,7 @@ export function useInstanceKYCDetails(): HttpResponse< const { fetcher } = useBackendInstanceRequest(); const { data, error } = useSWR< - HttpResponseOk<MerchantBackend.Instances.AccountKycRedirects>, + HttpResponseOk<MerchantBackend.KYC.AccountKycRedirects>, RequestError<MerchantBackend.ErrorDetail> >([`/private/kyc`], fetcher, { refreshInterval: 60 * 1000, diff --git a/packages/merchant-backoffice-ui/src/hooks/otp.ts b/packages/merchant-backoffice-ui/src/hooks/otp.ts new file mode 100644 index 000000000..3544b4881 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/hooks/otp.ts @@ -0,0 +1,223 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +const MOCKED_DEVICES: Record<string, MerchantBackend.OTP.OtpDeviceAddDetails> = { + "1": { + otp_description: "first device", + otp_algorithm: 1, + otp_device_id: "1", + otp_key: "123", + }, + "2": { + otp_description: "second device", + otp_algorithm: 0, + otp_device_id: "2", + otp_key: "456", + } +} + +export function useOtpDeviceAPI(): OtpDeviceAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendInstanceRequest(); + + const createOtpDevice = async ( + data: MerchantBackend.OTP.OtpDeviceAddDetails, + ): Promise<HttpResponseOk<void>> => { + // MOCKED_DEVICES[data.otp_device_id] = data + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/otp-devices`, { + method: "POST", + data, + }); + await mutateAll(/.*private\/otp-devices.*/); + return res; + }; + + const updateOtpDevice = async ( + deviceId: string, + data: MerchantBackend.OTP.OtpDevicePatchDetails, + ): Promise<HttpResponseOk<void>> => { + // MOCKED_DEVICES[deviceId].otp_algorithm = data.otp_algorithm + // MOCKED_DEVICES[deviceId].otp_ctr = data.otp_ctr + // MOCKED_DEVICES[deviceId].otp_device_description = data.otp_device_description + // MOCKED_DEVICES[deviceId].otp_key = data.otp_key + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/otp-devices/${deviceId}`, { + method: "PATCH", + data, + }); + await mutateAll(/.*private\/otp-devices.*/); + return res; + }; + + const deleteOtpDevice = async ( + deviceId: string, + ): Promise<HttpResponseOk<void>> => { + // delete MOCKED_DEVICES[deviceId] + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/otp-devices/${deviceId}`, { + method: "DELETE", + }); + await mutateAll(/.*private\/otp-devices.*/); + return res; + }; + + return { + createOtpDevice, + updateOtpDevice, + deleteOtpDevice, + }; +} + +export interface OtpDeviceAPI { + createOtpDevice: ( + data: MerchantBackend.OTP.OtpDeviceAddDetails, + ) => Promise<HttpResponseOk<void>>; + updateOtpDevice: ( + id: string, + data: MerchantBackend.OTP.OtpDevicePatchDetails, + ) => Promise<HttpResponseOk<void>>; + deleteOtpDevice: (id: string) => Promise<HttpResponseOk<void>>; +} + +export interface InstanceOtpDeviceFilter { +} + +export function useInstanceOtpDevices( + args?: InstanceOtpDeviceFilter, + updatePosition?: (id: string) => void, +): HttpResponsePaginated< + MerchantBackend.OTP.OtpDeviceSummaryResponse, + MerchantBackend.ErrorDetail +> { + // return { + // ok: true, + // loadMore: () => { }, + // loadMorePrev: () => { }, + // data: { + // otp_devices: Object.values(MOCKED_DEVICES).map(d => ({ + // device_description: d.otp_device_description, + // otp_device_id: d.otp_device_id + // })) + // } + // } + + const { fetcher } = useBackendInstanceRequest(); + + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.OTP.OtpDeviceSummaryResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/otp-devices`], fetcher); + + const [lastAfter, setLastAfter] = useState< + HttpResponse< + MerchantBackend.OTP.OtpDeviceSummaryResponse, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + }, [afterData /*, beforeData*/]); + + if (afterError) return afterError.cause; + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = + afterData && afterData.data.otp_devices.length < totalAfter; + const isReachingStart = false; + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.otp_devices.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = `${afterData.data.otp_devices[afterData.data.otp_devices.length - 1] + .otp_device_id + }`; + if (from && updatePosition) updatePosition(from); + } + }, + loadMorePrev: () => { + }, + }; + + const otp_devices = !afterData ? [] : (afterData || lastAfter).data.otp_devices; + if (loadingAfter /* || loadingBefore */) + return { loading: true, data: { otp_devices } }; + if (/*beforeData &&*/ afterData) { + return { ok: true, data: { otp_devices }, ...pagination }; + } + return { loading: true }; +} + +export function useOtpDeviceDetails( + deviceId: string, +): HttpResponse< + MerchantBackend.OTP.OtpDeviceDetails, + MerchantBackend.ErrorDetail +> { + // return { + // ok: true, + // data: { + // device_description: MOCKED_DEVICES[deviceId].otp_device_description, + // otp_algorithm: MOCKED_DEVICES[deviceId].otp_algorithm, + // otp_ctr: MOCKED_DEVICES[deviceId].otp_ctr + // } + // } + const { fetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.OTP.OtpDeviceDetails>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/otp-devices/${deviceId}`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) { + return data; + } + if (error) return error.cause; + return { loading: true }; +} diff --git a/packages/merchant-backoffice-ui/src/hooks/reserve.test.ts b/packages/merchant-backoffice-ui/src/hooks/reserve.test.ts index d2831ecff..b3eecd754 100644 --- a/packages/merchant-backoffice-ui/src/hooks/reserve.test.ts +++ b/packages/merchant-backoffice-ui/src/hooks/reserve.test.ts @@ -25,16 +25,16 @@ import { useInstanceReserves, useReserveDetails, useReservesAPI, - useTipDetails, + useRewardDetails, } from "./reserves.js"; import { ApiMockEnvironment } from "./testing.js"; import { - API_AUTHORIZE_TIP, - API_AUTHORIZE_TIP_FOR_RESERVE, + API_AUTHORIZE_REWARD, + API_AUTHORIZE_REWARD_FOR_RESERVE, API_CREATE_RESERVE, API_DELETE_RESERVE, API_GET_RESERVE_BY_ID, - API_GET_TIP_BY_ID, + API_GET_REWARD_BY_ID, API_LIST_RESERVES, } from "./urls.js"; import * as tests from "@gnu-taler/web-util/testing"; @@ -48,7 +48,7 @@ describe("reserve api interaction with listing", () => { reserves: [ { reserve_pub: "11", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, ], }, }); @@ -89,10 +89,10 @@ describe("reserve api interaction with listing", () => { reserves: [ { reserve_pub: "11", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, { reserve_pub: "22", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, ], }, }); @@ -115,10 +115,10 @@ describe("reserve api interaction with listing", () => { reserves: [ { reserve_pub: "11", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, { reserve_pub: "22", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, ], }); }, @@ -138,13 +138,13 @@ describe("reserve api interaction with listing", () => { reserves: [ { reserve_pub: "11", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, { reserve_pub: "22", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, { reserve_pub: "33", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, ], }, }); @@ -182,10 +182,10 @@ describe("reserve api interaction with listing", () => { reserves: [ { reserve_pub: "22", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, { reserve_pub: "33", - } as MerchantBackend.Tips.ReserveStatusEntry, + } as MerchantBackend.Rewards.ReserveStatusEntry, ], }, }); @@ -213,16 +213,16 @@ describe("reserve api interaction with listing", () => { }); describe("reserve api interaction with details", () => { - it("should evict cache when adding a tip for a specific reserve", async () => { + it("should evict cache when adding a reward for a specific reserve", async () => { const env = new ApiMockEnvironment(); env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { response: { accounts: [{ payto_uri: "payto://here" }], - tips: [{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }], - } as MerchantBackend.Tips.ReserveDetail, + rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], + } as MerchantBackend.Rewards.ReserveDetail, qparam: { - tips: "yes", + rewards: "yes", }, }); @@ -246,37 +246,37 @@ describe("reserve api interaction with details", () => { if (!query.ok) return; expect(query.data).deep.equals({ accounts: [{ payto_uri: "payto://here" }], - tips: [{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }], + rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], }); - env.addRequestExpectation(API_AUTHORIZE_TIP_FOR_RESERVE("11"), { + env.addRequestExpectation(API_AUTHORIZE_REWARD_FOR_RESERVE("11"), { request: { amount: "USD:12", justification: "not", next_url: "http://taler.net", }, response: { - tip_id: "id2", - taler_tip_uri: "uri", - tip_expiration: { t_s: 1 }, - tip_status_url: "url", + 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" }], - tips: [ - { reason: "why?", tip_id: "id1", total_amount: "USD:10" }, - { reason: "not", tip_id: "id2", total_amount: "USD:12" }, + rewards: [ + { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, + { reason: "not", reward_id: "id2", total_amount: "USD:12" }, ], - } as MerchantBackend.Tips.ReserveDetail, + } as MerchantBackend.Rewards.ReserveDetail, qparam: { - tips: "yes", + rewards: "yes", }, }); - api.authorizeTipReserve("11", { + api.authorizeRewardReserve("11", { amount: "USD:12", justification: "not", next_url: "http://taler.net", @@ -294,9 +294,9 @@ describe("reserve api interaction with details", () => { expect(query.data).deep.equals({ accounts: [{ payto_uri: "payto://here" }], - tips: [ - { reason: "why?", tip_id: "id1", total_amount: "USD:10" }, - { reason: "not", tip_id: "id2", total_amount: "USD:12" }, + rewards: [ + { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, + { reason: "not", reward_id: "id2", total_amount: "USD:12" }, ], }); }, @@ -308,16 +308,16 @@ describe("reserve api interaction with details", () => { expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); }); - it("should evict cache when adding a tip for a random reserve", async () => { + it("should evict cache when adding a reward for a random reserve", async () => { const env = new ApiMockEnvironment(); env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { response: { accounts: [{ payto_uri: "payto://here" }], - tips: [{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }], - } as MerchantBackend.Tips.ReserveDetail, + rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], + } as MerchantBackend.Rewards.ReserveDetail, qparam: { - tips: "yes", + rewards: "yes", }, }); @@ -341,37 +341,37 @@ describe("reserve api interaction with details", () => { if (!query.ok) return; expect(query.data).deep.equals({ accounts: [{ payto_uri: "payto://here" }], - tips: [{ reason: "why?", tip_id: "id1", total_amount: "USD:10" }], + rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], }); - env.addRequestExpectation(API_AUTHORIZE_TIP, { + env.addRequestExpectation(API_AUTHORIZE_REWARD, { request: { amount: "USD:12", justification: "not", next_url: "http://taler.net", }, response: { - tip_id: "id2", - taler_tip_uri: "uri", - tip_expiration: { t_s: 1 }, - tip_status_url: "url", + 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" }], - tips: [ - { reason: "why?", tip_id: "id1", total_amount: "USD:10" }, - { reason: "not", tip_id: "id2", total_amount: "USD:12" }, + rewards: [ + { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, + { reason: "not", reward_id: "id2", total_amount: "USD:12" }, ], - } as MerchantBackend.Tips.ReserveDetail, + } as MerchantBackend.Rewards.ReserveDetail, qparam: { - tips: "yes", + rewards: "yes", }, }); - api.authorizeTip({ + api.authorizeReward({ amount: "USD:12", justification: "not", next_url: "http://taler.net", @@ -387,9 +387,9 @@ describe("reserve api interaction with details", () => { expect(query.data).deep.equals({ accounts: [{ payto_uri: "payto://here" }], - tips: [ - { reason: "why?", tip_id: "id1", total_amount: "USD:10" }, - { reason: "not", tip_id: "id2", total_amount: "USD:12" }, + rewards: [ + { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, + { reason: "not", reward_id: "id2", total_amount: "USD:12" }, ], }); }, @@ -402,15 +402,15 @@ describe("reserve api interaction with details", () => { }); }); -describe("reserve api interaction with tip details", () => { - it("should list tips", async () => { +describe("reserve api interaction with reward details", () => { + it("should list rewards", async () => { const env = new ApiMockEnvironment(); - env.addRequestExpectation(API_GET_TIP_BY_ID("11"), { + env.addRequestExpectation(API_GET_REWARD_BY_ID("11"), { response: { total_picked_up: "USD:12", reason: "not", - } as MerchantBackend.Tips.TipDetails, + } as MerchantBackend.Rewards.RewardDetails, qparam: { pickups: "yes", }, @@ -418,7 +418,7 @@ describe("reserve api interaction with tip details", () => { const hookBehavior = await tests.hookBehaveLikeThis( () => { - const query = useTipDetails("11"); + const query = useRewardDetails("11"); return { query }; }, {}, diff --git a/packages/merchant-backoffice-ui/src/hooks/reserves.ts b/packages/merchant-backoffice-ui/src/hooks/reserves.ts index bb55b2474..b719bfbe6 100644 --- a/packages/merchant-backoffice-ui/src/hooks/reserves.ts +++ b/packages/merchant-backoffice-ui/src/hooks/reserves.ts @@ -31,11 +31,11 @@ export function useReservesAPI(): ReserveMutateAPI { const { request } = useBackendInstanceRequest(); const createReserve = async ( - data: MerchantBackend.Tips.ReserveCreateRequest, + data: MerchantBackend.Rewards.ReserveCreateRequest, ): Promise< - HttpResponseOk<MerchantBackend.Tips.ReserveCreateConfirmation> + HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation> > => { - const res = await request<MerchantBackend.Tips.ReserveCreateConfirmation>( + const res = await request<MerchantBackend.Rewards.ReserveCreateConfirmation>( `/private/reserves`, { method: "POST", @@ -49,12 +49,12 @@ export function useReservesAPI(): ReserveMutateAPI { return res; }; - const authorizeTipReserve = async ( + const authorizeRewardReserve = async ( pub: string, - data: MerchantBackend.Tips.TipCreateRequest, - ): Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>> => { - const res = await request<MerchantBackend.Tips.TipCreateConfirmation>( - `/private/reserves/${pub}/authorize-tip`, + data: MerchantBackend.Rewards.RewardCreateRequest, + ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => { + const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>( + `/private/reserves/${pub}/authorize-reward`, { method: "POST", data, @@ -67,11 +67,11 @@ export function useReservesAPI(): ReserveMutateAPI { return res; }; - const authorizeTip = async ( - data: MerchantBackend.Tips.TipCreateRequest, - ): Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>> => { - const res = await request<MerchantBackend.Tips.TipCreateConfirmation>( - `/private/tips`, + const authorizeReward = async ( + data: MerchantBackend.Rewards.RewardCreateRequest, + ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => { + const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>( + `/private/rewards`, { method: "POST", data, @@ -97,33 +97,33 @@ export function useReservesAPI(): ReserveMutateAPI { return res; }; - return { createReserve, authorizeTip, authorizeTipReserve, deleteReserve }; + return { createReserve, authorizeReward, authorizeRewardReserve, deleteReserve }; } export interface ReserveMutateAPI { createReserve: ( - data: MerchantBackend.Tips.ReserveCreateRequest, - ) => Promise<HttpResponseOk<MerchantBackend.Tips.ReserveCreateConfirmation>>; - authorizeTipReserve: ( + data: MerchantBackend.Rewards.ReserveCreateRequest, + ) => Promise<HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>>; + authorizeRewardReserve: ( id: string, - data: MerchantBackend.Tips.TipCreateRequest, - ) => Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>>; - authorizeTip: ( - data: MerchantBackend.Tips.TipCreateRequest, - ) => Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>>; + 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.Tips.TippingReserveStatus, + MerchantBackend.Rewards.RewardReserveStatus, MerchantBackend.ErrorDetail > { const { fetcher } = useBackendInstanceRequest(); const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Tips.TippingReserveStatus>, + HttpResponseOk<MerchantBackend.Rewards.RewardReserveStatus>, RequestError<MerchantBackend.ErrorDetail> >([`/private/reserves`], fetcher); @@ -136,13 +136,13 @@ export function useInstanceReserves(): HttpResponse< export function useReserveDetails( reserveId: string, ): HttpResponse< - MerchantBackend.Tips.ReserveDetail, + MerchantBackend.Rewards.ReserveDetail, MerchantBackend.ErrorDetail > { const { reserveDetailFetcher } = useBackendInstanceRequest(); const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Tips.ReserveDetail>, + HttpResponseOk<MerchantBackend.Rewards.ReserveDetail>, RequestError<MerchantBackend.ErrorDetail> >([`/private/reserves/${reserveId}`], reserveDetailFetcher, { refreshInterval: 0, @@ -158,15 +158,15 @@ export function useReserveDetails( return { loading: true }; } -export function useTipDetails( - tipId: string, -): HttpResponse<MerchantBackend.Tips.TipDetails, MerchantBackend.ErrorDetail> { - const { tipsDetailFetcher } = useBackendInstanceRequest(); +export function useRewardDetails( + rewardId: string, +): HttpResponse<MerchantBackend.Rewards.RewardDetails, MerchantBackend.ErrorDetail> { + const { rewardsDetailFetcher } = useBackendInstanceRequest(); const { data, error, isValidating } = useSWR< - HttpResponseOk<MerchantBackend.Tips.TipDetails>, + HttpResponseOk<MerchantBackend.Rewards.RewardDetails>, RequestError<MerchantBackend.ErrorDetail> - >([`/private/tips/${tipId}`], tipsDetailFetcher, { + >([`/private/rewards/${rewardId}`], rewardsDetailFetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, diff --git a/packages/merchant-backoffice-ui/src/hooks/urls.ts b/packages/merchant-backoffice-ui/src/hooks/urls.ts index 6b339c05a..00c5e95af 100644 --- a/packages/merchant-backoffice-ui/src/hooks/urls.ts +++ b/packages/merchant-backoffice-ui/src/hooks/urls.ts @@ -139,15 +139,15 @@ export const API_DELETE_PRODUCT = (id: string): Query<unknown, unknown> => ({ //////////////////// export const API_CREATE_RESERVE: Query< - MerchantBackend.Tips.ReserveCreateRequest, - MerchantBackend.Tips.ReserveCreateConfirmation + MerchantBackend.Rewards.ReserveCreateRequest, + MerchantBackend.Rewards.ReserveCreateConfirmation > = { method: "POST", url: "http://backend/instances/default/private/reserves", }; export const API_LIST_RESERVES: Query< unknown, - MerchantBackend.Tips.TippingReserveStatus + MerchantBackend.Rewards.RewardReserveStatus > = { method: "GET", url: "http://backend/instances/default/private/reserves", @@ -155,34 +155,34 @@ export const API_LIST_RESERVES: Query< export const API_GET_RESERVE_BY_ID = ( pub: string, -): Query<unknown, MerchantBackend.Tips.ReserveDetail> => ({ +): Query<unknown, MerchantBackend.Rewards.ReserveDetail> => ({ method: "GET", url: `http://backend/instances/default/private/reserves/${pub}`, }); -export const API_GET_TIP_BY_ID = ( +export const API_GET_REWARD_BY_ID = ( pub: string, -): Query<unknown, MerchantBackend.Tips.TipDetails> => ({ +): Query<unknown, MerchantBackend.Rewards.RewardDetails> => ({ method: "GET", - url: `http://backend/instances/default/private/tips/${pub}`, + url: `http://backend/instances/default/private/rewards/${pub}`, }); -export const API_AUTHORIZE_TIP_FOR_RESERVE = ( +export const API_AUTHORIZE_REWARD_FOR_RESERVE = ( pub: string, ): Query< - MerchantBackend.Tips.TipCreateRequest, - MerchantBackend.Tips.TipCreateConfirmation + MerchantBackend.Rewards.RewardCreateRequest, + MerchantBackend.Rewards.RewardCreateConfirmation > => ({ method: "POST", - url: `http://backend/instances/default/private/reserves/${pub}/authorize-tip`, + url: `http://backend/instances/default/private/reserves/${pub}/authorize-reward`, }); -export const API_AUTHORIZE_TIP: Query< - MerchantBackend.Tips.TipCreateRequest, - MerchantBackend.Tips.TipCreateConfirmation +export const API_AUTHORIZE_REWARD: Query< + MerchantBackend.Rewards.RewardCreateRequest, + MerchantBackend.Rewards.RewardCreateConfirmation > = { method: "POST", - url: `http://backend/instances/default/private/tips`, + url: `http://backend/instances/default/private/rewards`, }; export const API_DELETE_RESERVE = (id: string): Query<unknown, unknown> => ({ @@ -211,7 +211,7 @@ export const API_GET_INSTANCE_BY_ID = ( export const API_GET_INSTANCE_KYC_BY_ID = ( id: string, -): Query<unknown, MerchantBackend.Instances.AccountKycRedirects> => ({ +): Query<unknown, MerchantBackend.KYC.AccountKycRedirects> => ({ method: "GET", url: `http://backend/management/instances/${id}/kyc`, }); @@ -263,7 +263,7 @@ export const API_GET_CURRENT_INSTANCE: Query< export const API_GET_CURRENT_INSTANCE_KYC: Query< unknown, - MerchantBackend.Instances.AccountKycRedirects + MerchantBackend.KYC.AccountKycRedirects > = { method: "GET", url: `http://backend/instances/default/private/kyc`, diff --git a/packages/merchant-backoffice-ui/src/hooks/useSettings.ts b/packages/merchant-backoffice-ui/src/hooks/useSettings.ts index 5c0932f27..7dee9f896 100644 --- a/packages/merchant-backoffice-ui/src/hooks/useSettings.ts +++ b/packages/merchant-backoffice-ui/src/hooks/useSettings.ts @@ -19,6 +19,9 @@ import { Codec, buildCodecForObject, codecForBoolean, + codecForConstString, + codecForEither, + codecForString, } from "@gnu-taler/taler-util"; function parse_json_or_undefined<T>(str: string | undefined): T | undefined { @@ -31,29 +34,49 @@ function parse_json_or_undefined<T>(str: string | undefined): T | undefined { } export interface Settings { - advanceOrderMode: boolean + advanceOrderMode: boolean; + dateFormat: "ymd" | "dmy" | "mdy"; } const defaultSettings: Settings = { advanceOrderMode: false, + dateFormat: "ymd", } export const codecForSettings = (): Codec<Settings> => buildCodecForObject<Settings>() .property("advanceOrderMode", codecForBoolean()) + .property("dateFormat", codecForEither( + codecForConstString("ymd"), + codecForConstString("dmy"), + codecForConstString("mdy"), + )) .build("Settings"); const SETTINGS_KEY = buildStorageKey("merchant-settings", codecForSettings()); export function useSettings(): [ Readonly<Settings>, - <T extends keyof Settings>(key: T, value: Settings[T]) => void, + (s: Settings) => void, ] { - const { value, update } = useLocalStorage(SETTINGS_KEY); + const { value, update } = useLocalStorage(SETTINGS_KEY, defaultSettings); - const parsed: Settings = value ?? defaultSettings; - function updateField<T extends keyof Settings>(k: T, v: Settings[T]) { - update({ ...parsed, [k]: v }); + // const parsed: Settings = value ?? defaultSettings; + // function updateField<T extends keyof Settings>(k: T, v: Settings[T]) { + // const next = { ...parsed, [k]: v } + // update(next); + // } + return [value, update]; +} + +export function dateFormatForSettings(s: Settings): string { + switch (s.dateFormat) { + case "ymd": return "yyyy/MM/dd" + case "dmy": return "dd/MM/yyyy" + case "mdy": return "MM/dd/yyyy" } - return [parsed, updateField]; } + +export function datetimeFormatForSettings(s: Settings): string { + return dateFormatForSettings(s) + " HH:mm:ss" +}
\ No newline at end of file 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 14e2fcb46..a8108251d 100644 --- a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx @@ -19,7 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -29,9 +28,8 @@ import { FormProvider, } from "../../../components/form/FormProvider.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; -import { SetTokenNewInstanceModal } from "../../../components/modal/index.js"; import { MerchantBackend } from "../../../declaration.js"; -import { INSTANCE_ID_REGEX, PAYTO_REGEX } from "../../../utils/constants.js"; +import { INSTANCE_ID_REGEX } from "../../../utils/constants.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage & { @@ -47,19 +45,19 @@ interface Props { function with_defaults(id?: string): Partial<Entity> { return { id, - accounts: [], + // accounts: [], user_type: "business", + use_stefan: false, default_pay_delay: { d_us: 2 * 1000 * 60 * 60 * 1000 }, // two hours - default_wire_fee_amortization: 1, default_wire_transfer_delay: { d_us: 1000 * 2 * 60 * 60 * 24 * 1000 }, // two days }; } 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 [isTokenSet, updateIsTokenSet] = useState<boolean>(false); + // const [isTokenDialogActive, updateIsTokenDialogActive] = + // useState<boolean>(false); const { i18n } = useTranslationContext(); @@ -67,42 +65,24 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { id: !value.id ? i18n.str`required` : !INSTANCE_ID_REGEX.test(value.id) - ? i18n.str`is not valid` - : undefined, + ? 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_max_deposit_fee: !value.default_max_deposit_fee - ? i18n.str`required` - : !Amounts.parse(value.default_max_deposit_fee) - ? i18n.str`invalid format` - : undefined, - default_max_wire_fee: !value.default_max_wire_fee - ? i18n.str`required` - : !Amounts.parse(value.default_max_wire_fee) - ? i18n.str`invalid format` - : undefined, - default_wire_fee_amortization: - value.default_wire_fee_amortization === undefined - ? i18n.str`required` - : isNaN(value.default_wire_fee_amortization) - ? i18n.str`is not a number` - : value.default_wire_fee_amortization < 1 - ? i18n.str`must be 1 or greater` + ? 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` : undefined, @@ -129,12 +109,12 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { 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}` }; + // const newToken = value.auth_token; + // value.auth_token = undefined; + value.auth = { method: "external" } + // 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 @@ -142,16 +122,16 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { return onCreate(value as Entity); }; - function updateToken(token: string | null) { - valueHandler((old) => ({ - ...old, - auth_token: token === null ? undefined : token, - })); - } + // function updateToken(token: string | null) { + // valueHandler((old) => ({ + // ...old, + // auth_token: token === null ? undefined : token, + // })); + // } return ( <div> - <div class="columns"> + {/* <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> {isTokenDialogActive && ( @@ -174,9 +154,9 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { )} </div> <div class="column" /> - </div> + </div> */} - <section class="hero is-hero-bar"> + {/* <section class="hero is-hero-bar"> <div class="hero-body"> <div class="level"> <div class="level-item has-text-centered"> @@ -186,8 +166,8 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { !isTokenSet ? "button is-danger has-tooltip-bottom" : !value.auth_token - ? "button has-tooltip-bottom" - : "button is-info has-tooltip-bottom" + ? "button has-tooltip-bottom" + : "button is-info has-tooltip-bottom" } data-tooltip={i18n.str`change authorization configuration`} onClick={() => updateIsTokenDialogActive(true)} @@ -228,7 +208,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { </div> </div> </div> - </section> + </section> */} <section class="section is-main-section"> <div class="columns"> @@ -250,7 +230,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { )} <AsyncButton onClick={submit} - disabled={!isTokenSet || hasErrors} + disabled={hasErrors} data-tooltip={ hasErrors ? i18n.str`Need to complete marked fields and choose authorization method` diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx new file mode 100644 index 000000000..3336c53a4 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, FunctionalComponent } from "preact"; +import { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/Accounts/Create", + component: TestedComponent, +}; 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 new file mode 100644 index 000000000..3ac510f63 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx @@ -0,0 +1,175 @@ +/* + 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 { useBackendContext } from "../../../../context/backend.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; +import { parsePayUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; +import { InputSelector } from "../../../../components/form/InputSelector.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/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx new file mode 100644 index 000000000..7d33d25ce --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx @@ -0,0 +1,65 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useWebhookAPI } from "../../../../hooks/webhooks.js"; +import { Notification } from "../../../../utils/types.js"; +import { CreatePage } from "./CreatePage.js"; +import { useOtpDeviceAPI } from "../../../../hooks/otp.js"; +import { useBankAccountAPI } from "../../../../hooks/bank.js"; + +export type Entity = MerchantBackend.BankAccounts.AccountAddDetails; +interface Props { + onBack?: () => void; + onConfirm: () => void; +} + +export default function CreateValidator({ onConfirm, onBack }: Props): VNode { + const { createBankAccount } = useBankAccountAPI(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { i18n } = useTranslationContext(); + + return ( + <> + <NotificationCard notification={notif} /> + <CreatePage + onBack={onBack} + onCreate={(request: Entity) => { + return createBankAccount(request) + .then((d) => { + onConfirm() + }) + .catch((error) => { + setNotif({ + message: i18n.str`could not create device`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx new file mode 100644 index 000000000..6b4b63735 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { FunctionalComponent, h } from "preact"; +import { ListPage as TestedComponent } from "./ListPage.js"; + +export default { + title: "Pages/Accounts/List", + component: TestedComponent, +}; 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 new file mode 100644 index 000000000..24da755b9 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from "preact"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { CardTable } from "./Table.js"; + +export interface Props { + devices: MerchantBackend.BankAccounts.BankAccountEntry[]; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; + onCreate: () => void; + onDelete: (e: MerchantBackend.BankAccounts.BankAccountEntry) => void; + onSelect: (e: MerchantBackend.BankAccounts.BankAccountEntry) => void; +} + +export function ListPage({ + devices, + onCreate, + onDelete, + onSelect, + onLoadMoreBefore, + onLoadMoreAfter, +}: Props): VNode { + const form = { payto_uri: "" }; + + const { i18n } = useTranslationContext(); + return ( + <section class="section is-main-section"> + <CardTable + accounts={devices.map((o) => ({ + ...o, + id: String(o.h_wire), + }))} + onCreate={onCreate} + onDelete={onDelete} + onSelect={onSelect} + onLoadMoreBefore={onLoadMoreBefore} + hasMoreBefore={!onLoadMoreBefore} + onLoadMoreAfter={onLoadMoreAfter} + hasMoreAfter={!onLoadMoreAfter} + /> + </section> + ); +} 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 new file mode 100644 index 000000000..7d6db0782 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx @@ -0,0 +1,385 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { StateUpdater, useState } from "preact/hooks"; +import { MerchantBackend } from "../../../../declaration.js"; +import { parsePaytoUri, PaytoType, PaytoUri, PaytoUriBitcoin, PaytoUriIBAN, PaytoUriTalerBank, PaytoUriUnknown } from "@gnu-taler/taler-util"; + +type Entity = MerchantBackend.BankAccounts.BankAccountEntry; + +interface Props { + accounts: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + onCreate: () => void; + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; +} + +export function CardTable({ + accounts, + onCreate, + onDelete, + onSelect, + onLoadMoreAfter, + onLoadMoreBefore, + hasMoreAfter, + hasMoreBefore, +}: Props): VNode { + const [rowSelection, rowSelectionHandler] = useState<string[]>([]); + + const { i18n } = useTranslationContext(); + + return ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-newspaper" /> + </span> + <i18n.Translate>Bank accounts</i18n.Translate> + </p> + <div class="card-header-icon" aria-label="more options"> + <span + class="has-tooltip-left" + data-tooltip={i18n.str`add new accounts`} + > + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small"> + <i class="mdi mdi-plus mdi-36px" /> + </span> + </button> + </span> + </div> + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {accounts.length > 0 ? ( + <Table + accounts={accounts} + onDelete={onDelete} + onSelect={onSelect} + rowSelection={rowSelection} + rowSelectionHandler={rowSelectionHandler} + onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={onLoadMoreBefore} + hasMoreAfter={hasMoreAfter} + hasMoreBefore={hasMoreBefore} + /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + ); +} +interface TableProps { + rowSelection: string[]; + accounts: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; +} + +function toggleSelected<T>(id: T): (prev: T[]) => T[] { + return (prev: T[]): T[] => + prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id); +} + +function Table({ + accounts, + onLoadMoreAfter, + onDelete, + onSelect, + onLoadMoreBefore, + hasMoreAfter, + hasMoreBefore, +}: TableProps): VNode { + const { i18n } = useTranslationContext(); + const emptyList: Record<PaytoType | "unknown", { parsed: PaytoUri, acc: Entity }[]> = { "bitcoin": [], "x-taler-bank": [], "iban": [], "unknown": [], } + const accountsByType = accounts.reduce((prev, acc) => { + const parsed = parsePaytoUri(acc.payto_uri) + if (!parsed) return prev //skip + if (parsed.targetType !== "bitcoin" && parsed.targetType !== "x-taler-bank" && parsed.targetType !== "iban") { + prev["unknown"].push({ parsed, acc }) + } else { + prev[parsed.targetType].push({ parsed, acc }) + } + return prev + }, emptyList) + + const bitcoinAccounts = accountsByType["bitcoin"] + const talerbankAccounts = accountsByType["x-taler-bank"] + const ibanAccounts = accountsByType["iban"] + const unkownAccounts = accountsByType["unknown"] + + + return ( + <Fragment> + + {bitcoinAccounts.length > 0 && <div class="table-container"> + <p class="card-header-title"><i18n.Translate>Bitcoin type accounts</i18n.Translate></p> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Address</i18n.Translate> + </th> + <th> + <i18n.Translate>Sewgit 1</i18n.Translate> + </th> + <th> + <i18n.Translate>Sewgit 2</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {bitcoinAccounts.map(({ parsed, acc }, idx) => { + const ac = parsed as PaytoUriBitcoin + return ( + <tr key={idx}> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.targetPath} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.segwitAddrs[0]} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.segwitAddrs[1]} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected accounts from the database`} + onClick={() => onDelete(acc)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div>} + + + + {talerbankAccounts.length > 0 && <div class="table-container"> + <p class="card-header-title"><i18n.Translate>Taler type accounts</i18n.Translate></p> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Host</i18n.Translate> + </th> + <th> + <i18n.Translate>Account name</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {talerbankAccounts.map(({ parsed, acc }, idx) => { + const ac = parsed as PaytoUriTalerBank + return ( + <tr key={idx}> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.host} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.account} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected accounts from the database`} + onClick={() => onDelete(acc)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div>} + + {ibanAccounts.length > 0 && <div class="table-container"> + <p class="card-header-title"><i18n.Translate>IBAN type accounts</i18n.Translate></p> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Account name</i18n.Translate> + </th> + <th> + <i18n.Translate>IBAN</i18n.Translate> + </th> + <th> + <i18n.Translate>BIC</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {ibanAccounts.map(({ parsed, acc }, idx) => { + const ac = parsed as PaytoUriIBAN + return ( + <tr key={idx}> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.params["receiver-name"]} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.iban} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.bic ?? ""} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected accounts from the database`} + onClick={() => onDelete(acc)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div>} + + {unkownAccounts.length > 0 && <div class="table-container"> + <p class="card-header-title"><i18n.Translate>Other type accounts</i18n.Translate></p> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Type</i18n.Translate> + </th> + <th> + <i18n.Translate>Path</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {unkownAccounts.map(({ parsed, acc }, idx) => { + const ac = parsed as PaytoUriUnknown + return ( + <tr key={idx}> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.targetType} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.targetPath} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected accounts from the database`} + onClick={() => onDelete(acc)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div>} + </Fragment> + + ); +} + +function EmptyTable(): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"> + <i class="mdi mdi-emoticon-sad mdi-48px" /> + </span> + </p> + <p> + <i18n.Translate> + There is no accounts yet, add more pressing the + sign + </i18n.Translate> + </p> + </div> + ); +} 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 new file mode 100644 index 000000000..9788ce0ec --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx @@ -0,0 +1,107 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { + ErrorType, + HttpError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useInstanceOtpDevices, useOtpDeviceAPI } from "../../../../hooks/otp.js"; +import { Notification } from "../../../../utils/types.js"; +import { ListPage } from "./ListPage.js"; +import { useBankAccountAPI, useInstanceBankAccounts } from "../../../../hooks/bank.js"; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; + onNotFound: () => VNode; + onCreate: () => void; + onSelect: (id: string) => void; +} + +export default function ListValidators({ + onUnauthorized, + onLoadError, + onCreate, + onSelect, + onNotFound, +}: Props): VNode { + const [position, setPosition] = useState<string | undefined>(undefined); + const { i18n } = useTranslationContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { deleteBankAccount } = useBankAccountAPI(); + const result = useInstanceBankAccounts({ position }, (id) => setPosition(id)); + + if (result.loading) return <Loading />; + if (!result.ok) { + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.Unauthorized + ) + return onUnauthorized(); + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.NotFound + ) + return onNotFound(); + return onLoadError(result); + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <ListPage + devices={result.data.accounts} + onLoadMoreBefore={ + result.isReachingStart ? result.loadMorePrev : undefined + } + onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined} + onCreate={onCreate} + onSelect={(e) => { + onSelect(e.h_wire); + }} + onDelete={(e: MerchantBackend.BankAccounts.BankAccountEntry) => + deleteBankAccount(e.h_wire) + .then(() => + setNotif({ + message: i18n.str`bank account delete successfully`, + type: "SUCCESS", + }), + ) + .catch((error) => + setNotif({ + message: i18n.str`could not delete the bank account`, + type: "ERROR", + description: error.message, + }), + ) + } + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx new file mode 100644 index 000000000..fcb77b820 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, FunctionalComponent } from "preact"; +import { UpdatePage as TestedComponent } from "./UpdatePage.js"; + +export default { + title: "Pages/Validators/Update", + component: TestedComponent, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; 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 new file mode 100644 index 000000000..802f593cf --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx @@ -0,0 +1,114 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; + +type Entity = MerchantBackend.BankAccounts.AccountPatchDetails & WithId; + +interface Props { + onUpdate: (d: Entity) => Promise<void>; + onBack?: () => void; + account: Entity; +} +export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { + const { i18n } = useTranslationContext(); + + const [state, setState] = useState<Partial<Entity>>(account); + + const errors: FormErrors<Entity> = { + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + return onUpdate(state as any); + }; + + return ( + <div> + <section class="section"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span class="is-size-4"> + Account: <b>{account.id}</b> + </span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column is-four-fifths"> + <FormProvider + object={state} + valueHandler={setState} + errors={errors} + > + <Input<Entity> + name="credit_facade_url" + label={i18n.str`Description`} + tooltip={i18n.str`dddd`} + /> + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + </div> + </div> + </div> + </section> + </section> + </div> + ); +} 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 new file mode 100644 index 000000000..44dee7651 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx @@ -0,0 +1,96 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { + ErrorType, + HttpError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { useBankAccountAPI, useBankAccountDetails } from "../../../../hooks/bank.js"; +import { Notification } from "../../../../utils/types.js"; +import { UpdatePage } from "./UpdatePage.js"; + +export type Entity = MerchantBackend.BankAccounts.AccountPatchDetails & WithId; + +interface Props { + onBack?: () => void; + onConfirm: () => void; + onUnauthorized: () => VNode; + onNotFound: () => VNode; + onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; + bid: string; +} +export default function UpdateValidator({ + bid, + onConfirm, + onBack, + onUnauthorized, + onNotFound, + onLoadError, +}: Props): VNode { + const { updateBankAccount } = useBankAccountAPI(); + const result = useBankAccountDetails(bid); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + + const { i18n } = useTranslationContext(); + + if (result.loading) return <Loading />; + if (!result.ok) { + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.Unauthorized + ) + return onUnauthorized(); + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.NotFound + ) + return onNotFound(); + return onLoadError(result); + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + <UpdatePage + account={{ ...result.data, id: bid }} + onBack={onBack} + onUpdate={(data) => { + return updateBankAccount(bid, data) + .then(onConfirm) + .catch((error) => { + setNotif({ + message: i18n.str`could not update account`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx index e5937ab7b..21dadb1e3 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx @@ -36,14 +36,13 @@ interface Props { function convert( from: MerchantBackend.Instances.QueryInstancesResponse, ): Entity { - const { accounts: allAccounts, ...rest } = from; - const accounts = allAccounts.filter((a) => a.active); 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, ...rest, accounts }; + return { ...defaults, ...from }; } export function DetailPage({ selected }: Props): VNode { @@ -74,11 +73,6 @@ export function DetailPage({ selected }: Props): VNode { <div class="column is-6"> <FormProvider<Entity> object={value} valueHandler={valueHandler}> <Input<Entity> name="name" readonly label={i18n.str`Name`} /> - <Input<Entity> - name="accounts" - readonly - label={i18n.str`Account address`} - /> </FormProvider> </div> <div class="column" /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx index 3a6e0fbfe..367fabce2 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx @@ -51,17 +51,15 @@ function createExample<Props>( export const Example = createExample(TestedComponent, { selected: { - accounts: [], name: "name", auth: { method: "external" }, address: {}, + user_type: "business", jurisdiction: {}, - default_max_deposit_fee: "TESTKUDOS:2", - default_max_wire_fee: "TESTKUDOS:1", + use_stefan: true, default_pay_delay: { d_us: 1000 * 1000, //one second }, - default_wire_fee_amortization: 1, default_wire_transfer_delay: { d_us: 1000 * 1000, //one second }, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx index 6f50ac830..d33f64ada 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx @@ -54,5 +54,5 @@ export const Example = tests.createExample(TestedComponent, { payto_uri: "payto://iban/de123123123", }, ], - } as MerchantBackend.Instances.AccountKycRedirects, + } as MerchantBackend.KYC.AccountKycRedirects, }); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx index 67005d3cc..338081886 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx @@ -24,7 +24,7 @@ import { h, VNode } from "preact"; import { MerchantBackend } from "../../../../declaration.js"; export interface Props { - status: MerchantBackend.Instances.AccountKycRedirects; + status: MerchantBackend.KYC.AccountKycRedirects; } export function ListPage({ status }: Props): VNode { @@ -85,11 +85,11 @@ export function ListPage({ status }: Props): VNode { ); } interface PendingTableProps { - entries: MerchantBackend.Instances.MerchantAccountKycRedirect[]; + entries: MerchantBackend.KYC.MerchantAccountKycRedirect[]; } interface TimedOutTableProps { - entries: MerchantBackend.Instances.ExchangeKycTimeout[]; + entries: MerchantBackend.KYC.ExchangeKycTimeout[]; } function PendingTable({ entries }: PendingTableProps): VNode { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx index fcf611c3c..bd9f65718 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx @@ -42,12 +42,13 @@ function createExample<Props>( export const Example = createExample(TestedComponent, { instanceConfig: { - default_max_deposit_fee: "", - default_max_wire_fee: "", default_pay_delay: { d_us: 1000 * 1000 * 60 * 60, //one hour }, - default_wire_fee_amortization: 1, + default_wire_transfer_delay: { + d_us: 1000 * 1000 * 60 * 60, //one hour + }, + use_stefan: true, }, instanceInventory: [ { 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 fa9347c6e..ea2cf849a 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 @@ -44,6 +44,7 @@ import { OrderCreateSchema as schema } from "../../../../schemas/index.js"; import { rate } from "../../../../utils/amount.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { useSettings } from "../../../../hooks/useSettings.js"; +import { InputToggle } from "../../../../components/form/InputToggle.js"; interface Props { onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; @@ -52,34 +53,38 @@ interface Props { instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[]; } interface InstanceConfig { - default_max_wire_fee: string; - default_max_deposit_fee: string; - default_wire_fee_amortization: number; + use_stefan: boolean; default_pay_delay: Duration; + default_wire_transfer_delay: Duration; } -function with_defaults(config: InstanceConfig): Partial<Entity> { +function with_defaults(config: InstanceConfig, currency: string): Partial<Entity> { const defaultPayDeadline = !config.default_pay_delay || config.default_pay_delay.d_us === "forever" ? undefined : add(new Date(), { seconds: config.default_pay_delay.d_us / (1000 * 1000), }); + const defaultWireDeadline = + !config.default_wire_transfer_delay || config.default_wire_transfer_delay.d_us === "forever" + ? undefined + : add(new Date(), { + seconds: config.default_wire_transfer_delay.d_us / (1000 * 1000), + }); return { inventoryProducts: {}, products: [], pricing: {}, payments: { - max_wire_fee: config.default_max_wire_fee, - max_fee: config.default_max_deposit_fee, - wire_fee_amortization: config.default_wire_fee_amortization, + max_fee: undefined, pay_deadline: defaultPayDeadline, refund_deadline: defaultPayDeadline, createToken: true, + wire_transfer_deadline: defaultWireDeadline, }, shipping: {}, - extra: "", + extra: {}, }; } @@ -107,8 +112,6 @@ interface Payments { wire_transfer_deadline?: Date; auto_refund_deadline?: Date; max_fee?: string; - max_wire_fee?: string; - wire_fee_amortization?: number; createToken: boolean; minimum_age?: number; } @@ -118,7 +121,7 @@ interface Entity { pricing: Partial<Pricing>; payments: Partial<Payments>; shipping: Partial<Shipping>; - extra: string; + extra: Record<string, string>; } const stringIsValidJSON = (value: string) => { @@ -136,8 +139,9 @@ export function CreatePage({ instanceConfig, instanceInventory, }: Props): VNode { - const [value, valueHandler] = useState(with_defaults(instanceConfig)); 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] = useSettings() const inventoryList = Object.values(value.inventoryProducts || {}); @@ -160,10 +164,10 @@ export function CreatePage({ ? i18n.str`must be greater than 0` : undefined, }), - extra: - value.extra && !stringIsValidJSON(value.extra) - ? i18n.str`not a valid json` - : undefined, + // extra: + // value.extra && !stringIsValidJSON(value.extra) + // ? i18n.str`not a valid json` + // : undefined, payments: undefinedIfEmpty({ refund_deadline: !value.payments?.refund_deadline ? undefined @@ -202,6 +206,7 @@ export function CreatePage({ ) ? i18n.str`auto refund cannot be after refund deadline` : undefined, + }), shipping: undefinedIfEmpty({ delivery_date: !value.shipping?.delivery_date @@ -225,7 +230,7 @@ export function CreatePage({ amount: order.pricing.order_price, summary: order.pricing.summary, products: productList, - extra: value.extra, + extra: JSON.stringify(value.extra), pay_deadline: value.payments.pay_deadline ? { t_s: Math.floor(value.payments.pay_deadline.getTime() / 1000), @@ -250,9 +255,7 @@ export function CreatePage({ ), } : undefined, - wire_fee_amortization: value.payments.wire_fee_amortization as number, max_fee: value.payments.max_fee as string, - max_wire_fee: value.payments.max_wire_fee as string, delivery_date: value.shipping.delivery_date ? { t_s: value.shipping.delivery_date.getTime() / 1000 } @@ -326,6 +329,8 @@ export function CreatePage({ const totalAsString = Amounts.stringify(totalPrice.amount); const allProducts = productList.concat(inventoryList.map(asProduct)); + const [newField, setNewField] = useState("") + useEffect(() => { valueHandler((v) => { return { @@ -486,16 +491,61 @@ export function CreatePage({ name="payments.pay_deadline" label={i18n.str`Payment deadline`} tooltip={i18n.str`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`} + side={ + <span> + <button class="button" onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + pay_deadline: instance_default.payments?.pay_deadline + } + }) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } /> <InputDate name="payments.refund_deadline" label={i18n.str`Refund deadline`} tooltip={i18n.str`Time until which the order can be refunded by the merchant.`} + side={ + <span> + <button class="button" onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + refund_deadline: instance_default.payments?.refund_deadline + } + }) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } /> <InputDate name="payments.wire_transfer_deadline" label={i18n.str`Wire transfer deadline`} tooltip={i18n.str`Deadline for the exchange to make the wire transfer.`} + side={ + <span> + <button class="button" onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + wire_transfer_deadline: instance_default.payments?.wire_transfer_deadline + } + }) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } /> <InputDate name="payments.auto_refund_deadline" @@ -505,23 +555,13 @@ export function CreatePage({ <InputCurrency name="payments.max_fee" - label={i18n.str`Maximum deposit fee`} - tooltip={i18n.str`Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} + 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.`} /> - <InputCurrency - name="payments.max_wire_fee" - label={i18n.str`Maximum wire fee`} - tooltip={i18n.str`Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.`} - /> - <InputNumber - name="payments.wire_fee_amortization" - label={i18n.str`Wire fee amortization`} - tooltip={i18n.str`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`} - /> - <InputBoolean + <InputToggle name="payments.createToken" label={i18n.str`Create token`} - tooltip={i18n.str`Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.`} + tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`} /> <InputNumber name="payments.minimum_age" @@ -530,7 +570,7 @@ export function CreatePage({ help={ minAgeByProducts > 0 ? i18n.str`Min age defined by the producs is ${minAgeByProducts}` - : undefined + : i18n.str`No product with age restriction in this order` } /> </InputGroup> @@ -542,12 +582,53 @@ export function CreatePage({ label={i18n.str`Additional information`} tooltip={i18n.str`Custom information to be included in the contract for this order.`} > - <Input - name="extra" - inputType="multiline" - label={`Value`} - tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`} - /> + {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> 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 ffefd5302..2474fd042 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 @@ -38,7 +38,7 @@ export type Entity = { }; interface Props { onBack?: () => void; - onConfirm: () => void; + onConfirm: (id: string) => void; onUnauthorized: () => VNode; onNotFound: () => VNode; onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; @@ -95,7 +95,9 @@ export default function OrderCreate({ onBack={onBack} onCreate={(request: MerchantBackend.Orders.PostOrderRequest) => { createOrder(request) - .then(onConfirm) + .then((r) => { + return onConfirm(r.data.order_id) + }) .catch((error) => { setNotif({ message: "could not create order", diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx index e430ede56..6e73a01a5 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx @@ -50,13 +50,11 @@ const defaultContractTerm = { auditors: [], exchanges: [], max_fee: "TESTKUDOS:1", - max_wire_fee: "TESTKUDOS:1", merchant: {} as any, merchant_base_url: "http://merchant.url/", order_id: "2021.165-03GDFC26Y1NNG", products: [], summary: "text summary", - wire_fee_amortization: 1, wire_transfer_deadline: { t_s: "never", }, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx index 8965d41c9..e42adc2ff 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format, formatDistance } from "date-fns"; import { Fragment, h, VNode } from "preact"; @@ -38,6 +38,7 @@ import { MerchantBackend } from "../../../../declaration.js"; import { mergeRefunds } from "../../../../utils/amount.js"; import { RefundModal } from "../list/Table.js"; import { Event, Timeline } from "./Timeline.js"; +import { dateFormatForSettings, datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; type CT = MerchantBackend.ContractTerms; @@ -87,18 +88,6 @@ function ContractTerms({ value }: { value: CT }) { label={i18n.str`Max fee`} tooltip={i18n.str`maximum total deposit fee accepted by the merchant for this contract`} /> - <Input<CT> - readonly - name="max_wire_fee" - label={i18n.str`Max wire fee`} - tooltip={i18n.str`maximum wire fee accepted by the merchant`} - /> - <Input<CT> - readonly - name="wire_fee_amortization" - label={i18n.str`Wire fee amortization`} - tooltip={i18n.str`over how many customer transactions does the merchant expect to amortize wire fees on average`} - /> <InputDate<CT> readonly name="timestamp" @@ -204,6 +193,7 @@ function ClaimedPage({ const [value, valueHandler] = useState<Partial<Claimed>>(order); const { i18n } = useTranslationContext(); + const [settings] = useSettings() return ( <div> @@ -249,7 +239,7 @@ function ClaimedPage({ </b>{" "} {format( new Date(order.contract_terms.timestamp.t_s * 1000), - "yyyy-MM-dd HH:mm:ss", + datetimeFormatForSettings(settings) )} </p> </div> @@ -427,9 +417,10 @@ function PaidPage({ const [value, valueHandler] = useState<Partial<Paid>>(order); const { url } = useBackendContext(); - const refundHost = url.replace(/.*:\/\//, ""); // remove protocol part - const proto = url.startsWith("http://") ? "taler+http" : "taler"; - const refundurl = `${proto}://refund/${refundHost}/${order.contract_terms.order_id}/`; + const refundurl = stringifyRefundUri({ + merchantBaseUrl: url, + orderId: order.contract_terms.order_id + }) const refundable = new Date().getTime() < order.contract_terms.refund_deadline.t_s * 1000; const { i18n } = useTranslationContext(); @@ -618,6 +609,7 @@ function UnpaidPage({ }) { const [value, valueHandler] = useState<Partial<Unpaid>>(order); const { i18n } = useTranslationContext(); + const [settings] = useSettings() return ( <div> <section class="hero is-hero-bar"> @@ -666,7 +658,7 @@ function UnpaidPage({ ? "never" : format( new Date(order.creation_time.t_s * 1000), - "yyyy-MM-dd HH:mm:ss", + datetimeFormatForSettings(settings) )} </p> </div> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx index e68889a92..8c863f386 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx @@ -16,6 +16,7 @@ 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[]; @@ -30,7 +31,7 @@ export function Timeline({ events: e }: Props) { }); events.sort((a, b) => a.when.getTime() - b.when.getTime()); - + const [settings] = useSettings(); const [state, setState] = useState(events); useEffect(() => { const handle = setTimeout(() => { @@ -104,7 +105,7 @@ export function Timeline({ events: e }: Props) { } })()} <div class="timeline-content"> - {e.description !== "now" && <p class="heading">{format(e.when, "yyyy/MM/dd HH:mm:ss")}</p>} + {e.description !== "now" && <p class="heading">{format(e.when, datetimeFormatForSettings(settings))}</p>} <p>{e.description}</p> </div> </div> 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 37770d273..c29a6fa6e 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 @@ -26,19 +26,24 @@ 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 { errorOrderId: string | undefined; 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; @@ -66,18 +71,23 @@ export function ListPage({ 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 [orderId, setOrderId] = useState<string>(""); + const [settings] = useSettings(); return ( <section class="section is-main-section"> @@ -116,13 +126,13 @@ export function ListPage({ <div class="column is-two-thirds"> <div class="tabs" style={{ overflow: "inherit" }}> <ul> - <li class={isAllActive}> + <li class={isNotPaidActive}> <div class="has-tooltip-right" - data-tooltip={i18n.str`remove all filters`} + data-tooltip={i18n.str`only show paid orders`} > - <a onClick={onShowAll}> - <i18n.Translate>All</i18n.Translate> + <a onClick={onShowNotPaid}> + <i18n.Translate>New</i18n.Translate> </a> </div> </li> @@ -156,6 +166,26 @@ export function ListPage({ </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> @@ -180,8 +210,8 @@ export function ListPage({ class="input" type="text" readonly - value={!jumpToDate ? "" : format(jumpToDate, "yyyy/MM/dd")} - placeholder={i18n.str`date (YYYY/MM/DD)`} + value={!jumpToDate ? "" : format(jumpToDate, dateFormatForSettings(settings))} + placeholder={i18n.str`date (${dateFormatForSettings(settings)})`} onClick={() => { setPickDate(true); }} 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 3c927033b..608c9b20d 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 @@ -36,6 +36,7 @@ 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 { @@ -136,6 +137,7 @@ function Table({ hasMoreBefore, }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = useSettings(); return ( <div class="table-container"> {onLoadMoreBefore && ( @@ -173,9 +175,9 @@ function Table({ {i.timestamp.t_s === "never" ? "never" : format( - new Date(i.timestamp.t_s * 1000), - "yyyy/MM/dd HH:mm:ss", - )} + new Date(i.timestamp.t_s * 1000), + datetimeFormatForSettings(settings), + )} </td> <td onClick={(): void => onSelect(i)} @@ -260,6 +262,7 @@ export function RefundModal({ }: 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>>({}); @@ -281,8 +284,8 @@ export function RefundModal({ const totalRefundable = !orderPrice ? Amounts.zeroOfCurrency(totalRefunded.currency) : refunds.length - ? Amounts.sub(orderPrice, totalRefunded).amount - : orderPrice; + ? Amounts.sub(orderPrice, totalRefunded).amount + : orderPrice; const isRefundable = Amounts.isNonZero(totalRefundable); const duplicatedText = i18n.str`duplicated`; @@ -296,10 +299,10 @@ export function RefundModal({ 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, + ? 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, @@ -361,9 +364,9 @@ export function RefundModal({ {r.timestamp.t_s === "never" ? "never" : format( - new Date(r.timestamp.t_s * 1000), - "yyyy-MM-dd HH:mm:ss", - )} + new Date(r.timestamp.t_s * 1000), + datetimeFormatForSettings(settings), + )} </td> <td>{r.amount}</td> <td>{r.reason}</td> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx index 6888eda58..48f77e3d3 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx @@ -55,7 +55,7 @@ export default function OrderList({ onSelect, onNotFound, }: Props): VNode { - const [filter, setFilter] = useState<InstanceOrderFilter>({}); + const [filter, setFilter] = useState<InstanceOrderFilter>({ paid: "no" }); const [orderToBeRefunded, setOrderToBeRefunded] = useState< MerchantBackend.Orders.OrderHistoryEntry | undefined >(undefined); @@ -88,13 +88,15 @@ export default function OrderList({ return onLoadError(result); } - const isPaidActive = filter.paid === "yes" ? "is-active" : ""; + const isNotPaidActive = filter.paid === "no" ? "is-active" : ""; + const isPaidActive = filter.paid === "yes" && filter.wired === undefined ? "is-active" : ""; const isRefundedActive = filter.refunded === "yes" ? "is-active" : ""; - const isNotWiredActive = filter.wired === "no" ? "is-active" : ""; + const isNotWiredActive = filter.wired === "no" && filter.paid === "yes" ? "is-active" : ""; + const isWiredActive = filter.wired === "yes" ? "is-active" : ""; const isAllActive = filter.paid === undefined && - filter.refunded === undefined && - filter.wired === undefined + filter.refunded === undefined && + filter.wired === undefined ? "is-active" : ""; @@ -127,7 +129,9 @@ export default function OrderList({ errorOrderId={errorOrderId} isAllActive={isAllActive} isNotWiredActive={isNotWiredActive} + isWiredActive={isWiredActive} isPaidActive={isPaidActive} + isNotPaidActive={isNotPaidActive} isRefundedActive={isRefundedActive} jumpToDate={filter.date} onCopyURL={(id) => @@ -137,9 +141,11 @@ export default function OrderList({ onSearchOrderById={testIfOrderExistAndSelect} onSelectDate={setNewDate} onShowAll={() => setFilter({})} + onShowNotPaid={() => setFilter({ paid: "no" })} onShowPaid={() => setFilter({ paid: "yes" })} onShowRefunded={() => setFilter({ refunded: "yes" })} - onShowNotWired={() => setFilter({ wired: "no" })} + onShowNotWired={() => setFilter({ wired: "no", paid: "yes" })} + onShowWired={() => setFilter({ wired: "yes" })} /> {orderToBeRefunded && ( 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 6bbb89dfa..cbfe1d573 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 { MerchantBackend, WithId } from "../../../../declaration.js"; +import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; type Entity = MerchantBackend.Products.ProductDetail & WithId; @@ -122,6 +123,7 @@ 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"> @@ -134,7 +136,7 @@ function Table({ <i18n.Translate>Description</i18n.Translate> </th> <th> - <i18n.Translate>Sell</i18n.Translate> + <i18n.Translate>Price per unit</i18n.Translate> </th> <th> <i18n.Translate>Taxes</i18n.Translate> @@ -156,10 +158,10 @@ function Table({ const restStockInfo = !i.next_restock ? "" : i.next_restock.t_s === "never" - ? "never" - : `restock at ${format( + ? "never" + : `restock at ${format( new Date(i.next_restock.t_s * 1000), - "yyyy/MM/dd", + dateFormatForSettings(settings), )}`; let stockInfo: ComponentChildren = ""; if (i.total_stock < 0) { @@ -332,26 +334,35 @@ function FastProductWithInfiniteStockUpdateForm({ /> </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={i18n.str`update product with new price`} - > - <button - class="button is-info" - onClick={() => - onUpdate({ - ...product, - price: value.price, - }) - } - > - <i18n.Translate>Confirm</i18n.Translate> + <div class="buttons is-expanded"> + + <div class="buttons mt-5"> + + <button class="button " onClick={onCancel}> + <i18n.Translate>Clone</i18n.Translate> </button> - </span> + </div> + <div class="buttons is-right mt-5"> + <button class="button" onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + <span + class="has-tooltip-left" + data-tooltip={i18n.str`update product with new price`} + > + <button + class="button is-info" + onClick={() => + onUpdate({ + ...product, + price: value.price, + }) + } + > + <i18n.Translate>Confirm update</i18n.Translate> + </button> + </span> + </div> </div> </Fragment> ); @@ -374,9 +385,8 @@ function FastProductWithManagedStockUpdateForm({ const errors: FormErrors<FastProductUpdate> = { lost: currentStock + value.incoming < value.lost - ? `lost cannot be greater that current + incoming (max ${ - currentStock + value.incoming - })` + ? `lost cannot be greater that current + incoming (max ${currentStock + value.incoming + })` : undefined, }; 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 87efd1554..85c50e5ed 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 @@ -36,6 +36,7 @@ import { 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"; interface Props { onUnauthorized: () => VNode; @@ -53,6 +54,8 @@ export default function ProductList({ }: Props): VNode { const result = useInstanceProducts(); const { deleteProduct, updateProduct } = useProductAPI(); + const [deleting, setDeleting] = + useState<MerchantBackend.Products.ProductDetail & WithId | null>(null); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); @@ -97,22 +100,43 @@ export default function ProductList({ } onSelect={(product) => onSelect(product.id)} onDelete={(prod: MerchantBackend.Products.ProductDetail & WithId) => - deleteProduct(prod.id) - .then(() => + 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 delete successfully`, + message: i18n.str`Product "${deleting.description}" (ID: ${deleting.id}) has been deleted`, type: "SUCCESS", - }), - ) - .catch((error) => + }); + } catch (error) { setNotif({ - message: i18n.str`could not delete the product`, + message: i18n.str`Failed to delete product`, type: "ERROR", - description: error.message, - }), - ) - } - /> + 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/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx index fccb20121..2201e75a5 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx @@ -36,7 +36,7 @@ import { URL_REGEX, } from "../../../../utils/constants.js"; -type Entity = MerchantBackend.Tips.ReserveCreateRequest; +type Entity = MerchantBackend.Rewards.ReserveCreateRequest; interface Props { onCreate: (d: Entity) => Promise<void>; @@ -80,15 +80,15 @@ function ViewStep({ 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, + ? 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, + ? i18n.str`must be a valid URL` + : !exchangeQueryError + ? undefined + : exchangeQueryError, }; const hasErrors = Object.keys(errors).some( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx index 94fcdaff7..1d512c843 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx @@ -22,8 +22,8 @@ import { CreatedSuccessfully as Template } from "../../../../components/notifica import { MerchantBackend, WireAccount } from "../../../../declaration.js"; type Entity = { - request: MerchantBackend.Tips.ReserveCreateRequest; - response: MerchantBackend.Tips.ReserveCreateConfirmation; + request: MerchantBackend.Rewards.ReserveCreateRequest; + response: MerchantBackend.Rewards.ReserveCreateConfirmation; }; interface Props { @@ -98,15 +98,15 @@ export function ShowAccountsOfReserveAsQRWithLink({ 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); + .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)); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/index.tsx index 8a4fe1565..4bbaf1459 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/index.tsx @@ -39,9 +39,9 @@ export default function CreateReserve({ onBack, onConfirm }: Props): VNode { const [createdOk, setCreatedOk] = useState< | { - request: MerchantBackend.Tips.ReserveCreateRequest; - response: MerchantBackend.Tips.ReserveCreateConfirmation; - } + request: MerchantBackend.Rewards.ReserveCreateRequest; + response: MerchantBackend.Rewards.ReserveCreateConfirmation; + } | undefined >(undefined); @@ -54,7 +54,7 @@ export default function CreateReserve({ onBack, onConfirm }: Props): VNode { <NotificationCard notification={notif} /> <CreatePage onBack={onBack} - onCreate={(request: MerchantBackend.Tips.ReserveCreateRequest) => { + onCreate={(request: MerchantBackend.Rewards.ReserveCreateRequest) => { return createReserve(request) .then((r) => setCreatedOk({ request, response: r.data })) .catch((error) => { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx index b0173b5d3..d8840eeac 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx @@ -36,11 +36,12 @@ 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 { useTipDetails } from "../../../../hooks/reserves.js"; -import { TipInfo } from "./TipInfo.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.Tips.ReserveDetail; +type Entity = MerchantBackend.Rewards.ReserveDetail; type CT = MerchantBackend.ContractTerms; interface Props { @@ -116,14 +117,14 @@ export function DetailPage({ id, selected, onBack }: Props): VNode { <span class="icon"> <i class="mdi mdi-cash-register" /> </span> - <i18n.Translate>Tips</i18n.Translate> + <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.tips && selected.tips.length > 0 ? ( - <Table tips={selected.tips} /> + {selected.rewards && selected.rewards.length > 0 ? ( + <Table rewards={selected.rewards} /> ) : ( <EmptyTable /> )} @@ -163,7 +164,7 @@ function EmptyTable(): VNode { </p> <p> <i18n.Translate> - No tips has been authorized from this reserve + No reward has been authorized from this reserve </i18n.Translate> </p> </div> @@ -171,10 +172,10 @@ function EmptyTable(): VNode { } interface TableProps { - tips: MerchantBackend.Tips.TipStatusEntry[]; + rewards: MerchantBackend.Rewards.RewardStatusEntry[]; } -function Table({ tips }: TableProps): VNode { +function Table({ rewards }: TableProps): VNode { const { i18n } = useTranslationContext(); return ( <div class="table-container"> @@ -196,8 +197,8 @@ function Table({ tips }: TableProps): VNode { </tr> </thead> <tbody> - {tips.map((t, i) => { - return <TipRow id={t.tip_id} key={i} entry={t} />; + {rewards.map((t, i) => { + return <RewardRow id={t.reward_id} key={i} entry={t} />; })} </tbody> </table> @@ -205,15 +206,16 @@ function Table({ tips }: TableProps): VNode { ); } -function TipRow({ +function RewardRow({ id, entry, }: { id: string; - entry: MerchantBackend.Tips.TipStatusEntry; + entry: MerchantBackend.Rewards.RewardStatusEntry; }) { const [selected, setSelected] = useState(false); - const result = useTipDetails(id); + const result = useRewardDetails(id); + const [settings] = useSettings(); if (result.loading) { return ( <tr> @@ -242,11 +244,11 @@ function TipRow({ <Fragment> {selected && ( <SimpleModal - description="tip" + description="reward" active onCancel={() => setSelected(false)} > - <TipInfo id={id} amount={info.total_authorized} entity={info} /> + <RewardInfo id={id} amount={info.total_authorized} entity={info} /> </SimpleModal> )} <tr> @@ -256,7 +258,7 @@ function TipRow({ <td onClick={onSelect}> {info.expiration.t_s === "never" ? "never" - : format(info.expiration.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} + : format(info.expiration.t_s * 1000, datetimeFormatForSettings(settings))} </td> </tr> </Fragment> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx index 2592e2c6e..41c715f20 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx @@ -92,7 +92,7 @@ export const NotYetFunded = createExample(TestedComponent, { }, }); -export const FundedWithEmptyTips = createExample(TestedComponent, { +export const FundedWithEmptyRewards = createExample(TestedComponent, { id: "THISISTHERESERVEID", selected: { active: true, @@ -115,10 +115,10 @@ export const FundedWithEmptyTips = createExample(TestedComponent, { }, ], exchange_url: "http://exchange.taler/", - tips: [ + rewards: [ { reason: "asdasd", - tip_id: "123", + reward_id: "123", total_amount: "TESTKUDOS:1", }, ], diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/TipInfo.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx index 360d39aba..57a051ed7 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/TipInfo.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx @@ -17,8 +17,10 @@ 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"; +import { stringifyRewardUri } from "@gnu-taler/taler-util"; -type Entity = MerchantBackend.Tips.TipDetails; +type Entity = MerchantBackend.Rewards.RewardDetails; interface Props { id: string; @@ -26,11 +28,10 @@ interface Props { amount: string; } -export function TipInfo({ id, amount, entity }: Props): VNode { - const { url } = useBackendContext(); - const tipHost = url.replace(/.*:\/\//, ""); // remove protocol part - const proto = url.startsWith("http://") ? "taler+http" : "taler"; - const tipURL = `${proto}://tip/${tipHost}/${id}`; +export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode { + const { url: merchantBaseUrl } = useBackendContext(); + const [settings] = useSettings(); + const rewardURL = stringifyRewardUri({ merchantBaseUrl, merchantRewardId }) return ( <Fragment> <div class="field is-horizontal"> @@ -52,8 +53,8 @@ export function TipInfo({ id, amount, entity }: Props): VNode { <div class="field-body is-flex-grow-3"> <div class="field" style={{ overflowWrap: "anywhere" }}> <p class="control"> - <a target="_blank" rel="noreferrer" href={tipURL}> - {tipURL} + <a target="_blank" rel="noreferrer" href={rewardURL}> + {rewardURL} </a> </p> </div> @@ -73,9 +74,9 @@ export function TipInfo({ id, amount, entity }: Props): VNode { !entity.expiration || entity.expiration.t_s === "never" ? "never" : format( - entity.expiration.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - ) + entity.expiration.t_s * 1000, + datetimeFormatForSettings(settings), + ) } /> </p> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/AutorizeTipModal.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx index 1882f50d3..e205ee621 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/AutorizeTipModal.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx @@ -34,32 +34,32 @@ import { ContinueModal, } from "../../../../components/modal/index.js"; import { MerchantBackend } from "../../../../declaration.js"; -import { AuthorizeTipSchema } from "../../../../schemas/index.js"; +import { AuthorizeRewardSchema } from "../../../../schemas/index.js"; import { CreatedSuccessfully } from "./CreatedSuccessfully.js"; -interface AuthorizeTipModalProps { +interface AuthorizeRewardModalProps { onCancel: () => void; - onConfirm: (value: MerchantBackend.Tips.TipCreateRequest) => void; - tipAuthorized?: { - response: MerchantBackend.Tips.TipCreateConfirmation; - request: MerchantBackend.Tips.TipCreateRequest; + onConfirm: (value: MerchantBackend.Rewards.RewardCreateRequest) => void; + rewardAuthorized?: { + response: MerchantBackend.Rewards.RewardCreateConfirmation; + request: MerchantBackend.Rewards.RewardCreateRequest; }; } -export function AuthorizeTipModal({ +export function AuthorizeRewardModal({ onCancel, onConfirm, - tipAuthorized, -}: AuthorizeTipModalProps): VNode { + rewardAuthorized, +}: AuthorizeRewardModalProps): VNode { // const result = useOrderDetails(id) - type State = MerchantBackend.Tips.TipCreateRequest; + 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 { - AuthorizeTipSchema.validateSync(form, { abortEarly: false }); + AuthorizeRewardSchema.validateSync(form, { abortEarly: false }); } catch (err) { if (err instanceof yup.ValidationError) { const yupErrors = err.inner as any[]; @@ -77,12 +77,12 @@ export function AuthorizeTipModal({ const validateAndConfirm = () => { onConfirm(form as State); }; - if (tipAuthorized) { + if (rewardAuthorized) { return ( - <ContinueModal description="tip" active onConfirm={onCancel}> + <ContinueModal description="reward" active onConfirm={onCancel}> <CreatedSuccessfully - entity={tipAuthorized.response} - request={tipAuthorized.request} + entity={rewardAuthorized.response} + request={rewardAuthorized.request} onConfirm={onCancel} /> </ContinueModal> @@ -91,7 +91,7 @@ export function AuthorizeTipModal({ return ( <ConfirmModal - description="tip" + description="New reward" active onCancel={onCancel} disabled={hasErrors} @@ -105,18 +105,18 @@ export function AuthorizeTipModal({ <InputCurrency<State> name="amount" label={i18n.str`Amount`} - tooltip={i18n.str`amount of tip`} + tooltip={i18n.str`amount of reward`} /> <Input<State> name="justification" label={i18n.str`Justification`} inputType="multiline" - tooltip={i18n.str`reason for the tip`} + tooltip={i18n.str`reason for the reward`} /> <Input<State> name="next_url" - label={i18n.str`URL after tip`} - tooltip={i18n.str`URL to visit after tip payment`} + label={i18n.str`URL after reward`} + tooltip={i18n.str`URL to visit after reward payment`} /> </FormProvider> </ConfirmModal> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx index 643651b52..b78236bc7 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx @@ -17,12 +17,13 @@ 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.Tips.TipCreateConfirmation; +type Entity = MerchantBackend.Rewards.RewardCreateConfirmation; interface Props { entity: Entity; - request: MerchantBackend.Tips.TipCreateRequest; + request: MerchantBackend.Rewards.RewardCreateRequest; onConfirm: () => void; onCreateAnother?: () => void; } @@ -33,6 +34,7 @@ export function CreatedSuccessfully({ onConfirm, onCreateAnother, }: Props): VNode { + const [settings] = useSettings(); return ( <Fragment> <div class="field is-horizontal"> @@ -66,7 +68,7 @@ export function CreatedSuccessfully({ <div class="field-body is-flex-grow-3"> <div class="field"> <p class="control"> - <input readonly class="input" value={entity.tip_status_url} /> + <input readonly class="input" value={entity.reward_status_url} /> </p> </div> </div> @@ -82,13 +84,13 @@ export function CreatedSuccessfully({ class="input" readonly value={ - !entity.tip_expiration || - entity.tip_expiration.t_s === "never" + !entity.reward_expiration || + entity.reward_expiration.t_s === "never" ? "never" : format( - entity.tip_expiration.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - ) + entity.reward_expiration.t_s * 1000, + datetimeFormatForSettings(settings), + ) } /> </p> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx index fe305f4fd..b070bbde3 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx @@ -25,12 +25,6 @@ import { CardTable as TestedComponent } from "./Table.js"; export default { title: "Pages/Reserve/List", component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onDelete: { action: "onDelete" }, - onNewTip: { action: "onNewTip" }, - onSelect: { action: "onSelect" }, - }, }; function createExample<Props>( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/Table.tsx index 1f229d7cb..795e7ec82 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/Table.tsx @@ -23,12 +23,13 @@ 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.Tips.ReserveStatusEntry & WithId; +type Entity = MerchantBackend.Rewards.ReserveStatusEntry & WithId; interface Props { instances: Entity[]; - onNewTip: (id: Entity) => void; + onNewReward: (id: Entity) => void; onSelect: (id: Entity) => void; onDelete: (id: Entity) => void; onCreate: () => void; @@ -38,7 +39,7 @@ export function CardTable({ instances, onCreate, onSelect, - onNewTip, + onNewReward, onDelete, }: Props): VNode { const [withoutFunds, withFunds] = instances.reduce((prev, current) => { @@ -70,7 +71,7 @@ export function CardTable({ <div class="table-wrapper has-mobile-cards"> <TableWithoutFund instances={withoutFunds} - onNewTip={onNewTip} + onNewReward={onNewReward} onSelect={onSelect} onDelete={onDelete} /> @@ -108,7 +109,7 @@ export function CardTable({ {withFunds.length > 0 ? ( <Table instances={withFunds} - onNewTip={onNewTip} + onNewReward={onNewReward} onSelect={onSelect} onDelete={onDelete} /> @@ -124,13 +125,14 @@ export function CardTable({ } interface TableProps { instances: Entity[]; - onNewTip: (id: Entity) => void; + onNewReward: (id: Entity) => void; onDelete: (id: Entity) => void; onSelect: (id: Entity) => void; } -function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { +function Table({ instances, onNewReward, onSelect, onDelete }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = useSettings(); return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> @@ -164,7 +166,7 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { > {i.creation_time.t_s === "never" ? "never" - : format(i.creation_time.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} + : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))} </td> <td onClick={(): void => onSelect(i)} @@ -173,9 +175,9 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { {i.expiration_time.t_s === "never" ? "never" : format( - i.expiration_time.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - )} + i.expiration_time.t_s * 1000, + datetimeFormatForSettings(settings), + )} </td> <td onClick={(): void => onSelect(i)} @@ -207,11 +209,11 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { </button> <button class="button is-small is-info has-tooltip-left" - data-tooltip={i18n.str`authorize new tip from selected reserve`} + data-tooltip={i18n.str`authorize new reward from selected reserve`} type="button" - onClick={(): void => onNewTip(i)} + onClick={(): void => onNewReward(i)} > - New Tip + New Reward </button> </div> </td> @@ -249,6 +251,7 @@ function TableWithoutFund({ 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"> @@ -276,7 +279,7 @@ function TableWithoutFund({ > {i.creation_time.t_s === "never" ? "never" - : format(i.creation_time.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} + : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))} </td> <td onClick={(): void => onSelect(i)} @@ -285,9 +288,9 @@ function TableWithoutFund({ {i.expiration_time.t_s === "never" ? "never" : format( - i.expiration_time.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - )} + i.expiration_time.t_s * 1000, + datetimeFormatForSettings(settings), + )} </td> <td onClick={(): void => onSelect(i)} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/index.tsx index 14387c2a9..b26ff0000 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/index.tsx @@ -34,9 +34,10 @@ import { useReservesAPI, } from "../../../../hooks/reserves.js"; import { Notification } from "../../../../utils/types.js"; -import { AuthorizeTipModal } from "./AutorizeTipModal.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; @@ -46,12 +47,12 @@ interface Props { onCreate: () => void; } -interface TipConfirmation { - response: MerchantBackend.Tips.TipCreateConfirmation; - request: MerchantBackend.Tips.TipCreateRequest; +interface RewardConfirmation { + response: MerchantBackend.Rewards.RewardCreateConfirmation; + request: MerchantBackend.Rewards.RewardCreateRequest; } -export default function ListTips({ +export default function ListRewards({ onUnauthorized, onLoadError, onNotFound, @@ -59,14 +60,16 @@ export default function ListTips({ onCreate, }: Props): VNode { const result = useInstanceReserves(); - const { deleteReserve, authorizeTipReserve } = useReservesAPI(); + const { deleteReserve, authorizeRewardReserve } = useReservesAPI(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); - const [reserveForTip, setReserveForTip] = useState<string | undefined>( + const [reserveForReward, setReserveForReward] = useState<string | undefined>( undefined, ); - const [tipAuthorized, setTipAuthorized] = useState< - TipConfirmation | undefined + const [deleting, setDeleting] = + useState<MerchantBackend.Rewards.ReserveStatusEntry | null>(null); + const [rewardAuthorized, setRewardAuthorized] = useState< + RewardConfirmation | undefined >(undefined); if (result.loading) return <Loading />; @@ -88,30 +91,30 @@ export default function ListTips({ <section class="section is-main-section"> <NotificationCard notification={notif} /> - {reserveForTip && ( - <AuthorizeTipModal + {reserveForReward && ( + <AuthorizeRewardModal onCancel={() => { - setReserveForTip(undefined); - setTipAuthorized(undefined); + setReserveForReward(undefined); + setRewardAuthorized(undefined); }} - tipAuthorized={tipAuthorized} + rewardAuthorized={rewardAuthorized} onConfirm={async (request) => { try { - const response = await authorizeTipReserve( - reserveForTip, + const response = await authorizeRewardReserve( + reserveForReward, request, ); - setTipAuthorized({ + setRewardAuthorized({ request, response: response.data, }); } catch (error) { setNotif({ - message: i18n.str`could not create the tip`, + message: i18n.str`could not create the reward`, type: "ERROR", description: error instanceof Error ? error.message : undefined, }); - setReserveForTip(undefined); + setReserveForReward(undefined); } }} /> @@ -122,10 +125,47 @@ export default function ListTips({ .filter((r) => r.active) .map((o) => ({ ...o, id: o.reserve_pub }))} onCreate={onCreate} - onDelete={(reserve) => deleteReserve(reserve.reserve_pub)} + onDelete={(reserve) => { + setDeleting(reserve) + }} onSelect={(reserve) => onSelect(reserve.id)} - onNewTip={(reserve) => setReserveForTip(reserve.id)} + onNewReward={(reserve) => setReserveForReward(reserve.id)} /> + + {deleting && ( + <ConfirmModal + label={`Delete reserve`} + description={`Delete the reserve`} + danger + active + onCancel={() => setDeleting(null)} + onConfirm={async (): Promise<void> => { + try { + await deleteReserve(deleting.reserve_pub); + setNotif({ + message: i18n.str`Reserve for "${deleting.merchant_initial_amount}" (ID: ${deleting.reserve_pub}) has been deleted`, + type: "SUCCESS", + }); + } catch (error) { + setNotif({ + message: i18n.str`Failed to delete reserve`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + } + setDeleting(null); + }} + > + <p> + If you delete the reserve for <b>"{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/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx index e20b9bc27..8629d8dee 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 @@ -24,7 +24,7 @@ import { MerchantTemplateContractDetails, } 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 { @@ -35,17 +35,16 @@ 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 { InputSelector } from "../../../../components/form/InputSelector.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { useBackendContext } from "../../../../context/backend.js"; +import { useInstanceContext } from "../../../../context/instance.js"; import { MerchantBackend } from "../../../../declaration.js"; import { - isBase32RFC3548Charset, - randomBase32Key, + isBase32RFC3548Charset } from "../../../../utils/crypto.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; -import { QR } from "../../../../components/exception/QR.js"; -import { useInstanceContext } from "../../../../context/instance.js"; +import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js"; +import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; type Entity = MerchantBackend.Template.TemplateAddDetails; @@ -54,16 +53,11 @@ interface Props { 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 { id: instanceId } = useInstanceContext(); - const issuer = new URL(backend.url).hostname; + const devices = useInstanceOtpDevices() - const [showKey, setShowKey] = useState(false); const [state, setState] = useState<Partial<Entity>>({ template_contract: { minimum_age: 0, @@ -78,7 +72,11 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { : Amounts.parse(state.template_contract?.amount); const errors: FormErrors<Entity> = { - template_id: !state.template_id ? i18n.str`should not be empty` : undefined, + template_id: !state.template_id + ? i18n.str`should not be empty` + : !/[a-zA-Z0-9]*/.test(state.template_id) + ? i18n.str`no valid. only characters and numbers` + : undefined, template_description: !state.template_description ? i18n.str`should not be empty` : undefined, @@ -104,15 +102,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { ? i18n.str`to short` : undefined, } as Partial<MerchantTemplateContractDetails>), - pos_key: !state.pos_key - ? !state.pos_algorithm - ? undefined - : i18n.str`required` - : !isBase32RFC3548Charset(state.pos_key) - ? i18n.str`just letters and numbers from 2 to 7` - : state.pos_key.length !== 32 - ? i18n.str`size of the key should be 32` - : undefined, }; const hasErrors = Object.keys(errors).some( @@ -124,7 +113,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { return onCreate(state as any); }; - const qrText = `otpauth://totp/${instanceId}/${state.template_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${state.pos_key}`; + const deviceList = !devices.ok ? [] : devices.data.otp_devices return ( <div> @@ -139,7 +128,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { > <InputWithAddon<Entity> name="template_id" - help={`${backend.url}/instances/templates/${state.template_id ?? ""}`} + help={`${backend.url}/templates/${state.template_id ?? ""}`} label={i18n.str`Identifier`} tooltip={i18n.str`Name of the template in URLs.`} /> @@ -172,83 +161,21 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { help="" tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} /> - <InputSelector<Entity> - name="pos_algorithm" - label={i18n.str`Verification algorithm`} - tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} - values={algorithms} - toStr={(v) => algorithmsNames[v]} - fromStr={(v) => Number(v)} + <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 + }))} /> - {state.pos_algorithm && state.pos_algorithm > 0 ? ( - <Fragment> - <InputWithAddon<Entity> - name="pos_key" - label={i18n.str`Point-of-sale key`} - inputType={showKey ? "text" : "password"} - help="Be sure to be very hard to guess or use the random generator" - tooltip={i18n.str`Useful to validate the purchase`} - fromStr={(v) => v.toUpperCase()} - addonAfter={ - <span class="icon"> - {showKey ? ( - <i class="mdi mdi-eye" /> - ) : ( - <i class="mdi mdi-eye-off" /> - )} - </span> - } - side={ - <span style={{ display: "flex" }}> - <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-3" - onClick={(e) => { - const pos_key = randomBase32Key(); - setState((s) => ({ ...s, pos_key })); - }} - > - <i18n.Translate>random</i18n.Translate> - </button> - <button - data-tooltip={ - showKey - ? i18n.str`show secret key` - : i18n.str`hide secret key` - } - class="button is-info mr-3" - onClick={(e) => { - setShowKey(!showKey); - }} - > - {showKey ? ( - <i18n.Translate>hide</i18n.Translate> - ) : ( - <i18n.Translate>show</i18n.Translate> - )} - </button> - </span> - } - /> - {showKey && ( - <Fragment> - <QR text={qrText} /> - <div - style={{ - color: "grey", - fontSize: "small", - width: 200, - textAlign: "center", - margin: "auto", - wordBreak: "break-all", - }} - > - {qrText} - </div> - </Fragment> - )} - </Fragment> - ) : undefined} + </FormProvider> <div class="buttons is-right mt-5"> 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 2f91298bf..3c9bb231c 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 @@ -36,6 +36,7 @@ import { import { Notification } from "../../../../utils/types.js"; import { ListPage } from "./ListPage.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { ConfirmModal } from "../../../../components/modal/index.js"; interface Props { onUnauthorized: () => VNode; @@ -61,6 +62,8 @@ export default function ListTemplates({ const [notif, setNotif] = useState<Notification | undefined>(undefined); const { deleteTemplate } = 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) { @@ -97,23 +100,45 @@ export default function ListTemplates({ onQR={(e) => { onQR(e.template_id); }} - onDelete={(e: MerchantBackend.Template.TemplateEntry) => - deleteTemplate(e.template_id) - .then(() => + 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 delete successfully`, + message: i18n.str`Template "${deleting.template_description}" (ID: ${deleting.template_id}) has been deleted`, type: "SUCCESS", - }), - ) - .catch((error) => + }); + } catch (error) { setNotif({ - message: i18n.str`could not delete the template`, + message: i18n.str`Failed to delete template`, type: "ERROR", - description: error.message, - }), - ) - } - /> + 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> + )} </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx index 0f30efafd..c65cf6a19 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { QR } from "../../../../components/exception/QR.js"; @@ -35,35 +35,32 @@ import { useConfigContext } from "../../../../context/config.js"; import { useInstanceContext } from "../../../../context/instance.js"; import { MerchantBackend } from "../../../../declaration.js"; import { stringifyPayTemplateUri } from "@gnu-taler/taler-util"; +import { useOtpDeviceDetails } from "../../../../hooks/otp.js"; +import { Loading } from "../../../../components/exception/loading.js"; type Entity = MerchantBackend.Template.UsingTemplateDetails; interface Props { - template: MerchantBackend.Template.TemplateDetails; + contract: MerchantBackend.Template.TemplateContractDetails; id: string; onBack?: () => void; } -export function QrPage({ template, id: templateId, onBack }: Props): VNode { +export function QrPage({ contract, id: templateId, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const { url: backendUrl } = useBackendContext(); const { id: instanceId } = useInstanceContext(); const config = useConfigContext(); - const [setupTOTP, setSetupTOTP] = useState(false); const [state, setState] = useState<Partial<Entity>>({ - amount: template.template_contract.amount, - summary: template.template_contract.summary, + amount: contract.amount, + summary: contract.summary, }); const errors: FormErrors<Entity> = {}; - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const fixedAmount = !!template.template_contract.amount; - const fixedSummary = !!template.template_contract.summary; + const fixedAmount = !!contract.amount; + const fixedSummary = !!contract.summary; const templateParams: Record<string, string> = {} if (!fixedAmount) { @@ -89,40 +86,9 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode { const issuer = encodeURIComponent( `${new URL(backendUrl).host}/${instanceId}`, ); - const oauthUri = !template.pos_algorithm - ? undefined - : template.pos_algorithm === 1 - ? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30` - : template.pos_algorithm === 2 - ? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30` - : undefined; - - const keySlice = template.pos_key?.substring(0, 4); - - const oauthUriWithoutSecret = !template.pos_algorithm - ? undefined - : template.pos_algorithm === 1 - ? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30` - : template.pos_algorithm === 2 - ? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30` - : undefined; + return ( <div> - {oauthUri && ( - <ConfirmModal - description="Setup TOTP" - active={setupTOTP} - onCancel={() => { - setSetupTOTP(false); - }} - > - <p>Scan this qr code with your TOTP device</p> - <QR text={oauthUri} /> - <pre style={{ textAlign: "center" }}> - <a href={oauthUri}>{oauthUriWithoutSecret}</a> - </pre> - </ConfirmModal> - )} <section class="section is-main-section"> <div class="columns"> <div class="column" /> @@ -176,14 +142,6 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode { > <i18n.Translate>Print</i18n.Translate> </button> - {oauthUri && ( - <button - class="button is-info" - onClick={() => setSetupTOTP(true)} - > - <i18n.Translate>Setup TOTP</i18n.Translate> - </button> - )} </div> </div> <div class="column" /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx index 1f74afc2b..7db7478f7 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx @@ -74,7 +74,7 @@ export default function TemplateQrPage({ return ( <> <NotificationCard notification={notif} /> - <QrPage template={result.data} id={tid} onBack={onBack} /> + <QrPage contract={result.data.template_contract} id={tid} onBack={onBack} /> </> ); } 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 30e5502bb..30d47385c 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 @@ -61,10 +61,7 @@ const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"]; export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const backend = useBackendContext(); - const { id: instanceId } = useInstanceContext(); - const issuer = new URL(backend.url).hostname; - const [showKey, setShowKey] = useState(false); const [state, setState] = useState<Partial<Entity>>(template); const parsedPrice = !state.template_contract?.amount @@ -78,34 +75,25 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { template_contract: !state.template_contract ? undefined : undefinedIfEmpty({ - amount: !state.template_contract?.amount - ? undefined - : !parsedPrice + amount: !state.template_contract?.amount + ? undefined + : !parsedPrice ? i18n.str`not valid` : Amounts.isZero(parsedPrice) - ? i18n.str`must be greater than 0` - : undefined, - minimum_age: - state.template_contract.minimum_age < 0 - ? i18n.str`should be greater that 0` + ? i18n.str`must be greater than 0` : undefined, - pay_duration: !state.template_contract.pay_duration - ? i18n.str`can't be empty` - : state.template_contract.pay_duration.d_us === "forever" + 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<MerchantTemplateContractDetails>), - pos_key: !state.pos_key - ? !state.pos_algorithm - ? undefined - : i18n.str`required` - : !isBase32RFC3548Charset(state.pos_key) - ? i18n.str`just letters and numbers from 2 to 7` - : state.pos_key.length !== 32 - ? i18n.str`size of the key should be 32` - : undefined, + ? i18n.str`to short` + : undefined, + } as Partial<MerchantTemplateContractDetails>), }; const hasErrors = Object.keys(errors).some( @@ -117,7 +105,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { return onUpdate(state as any); }; - const qrText = `otpauth://totp/${instanceId}/${state.id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${state.pos_key}`; return ( <div> @@ -128,7 +115,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { <div class="level-left"> <div class="level-item"> <span class="is-size-4"> - {backend.url}/instances/template/{template.id} + {backend.url}/templates/{template.id} </span> </div> </div> @@ -182,84 +169,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { help="" tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} /> - <InputSelector<Entity> - name="pos_algorithm" - label={i18n.str`Verification algorithm`} - tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} - values={algorithms} - toStr={(v) => algorithmsNames[v]} - fromStr={(v) => Number(v)} - /> - {state.pos_algorithm && state.pos_algorithm > 0 ? ( - <Fragment> - <InputWithAddon<Entity> - name="pos_key" - label={i18n.str`Point-of-sale key`} - inputType={showKey ? "text" : "password"} - help="Be sure to be very hard to guess or use the random generator" - expand - tooltip={i18n.str`Useful to validate the purchase`} - fromStr={(v) => v.toUpperCase()} - addonAfter={ - <span class="icon"> - {showKey ? ( - <i class="mdi mdi-eye" /> - ) : ( - <i class="mdi mdi-eye-off" /> - )} - </span> - } - side={ - <span style={{ display: "flex" }}> - <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-3" - onClick={(e) => { - const pos_key = randomBase32Key(); - setState((s) => ({ ...s, pos_key })); - }} - > - <i18n.Translate>random</i18n.Translate> - </button> - <button - data-tooltip={ - showKey - ? i18n.str`show secret key` - : i18n.str`hide secret key` - } - class="button is-info mr-3" - onClick={(e) => { - setShowKey(!showKey); - }} - > - {showKey ? ( - <i18n.Translate>hide</i18n.Translate> - ) : ( - <i18n.Translate>show</i18n.Translate> - )} - </button> - </span> - } - /> - {showKey && ( - <Fragment> - <QR text={qrText} /> - <div - style={{ - color: "grey", - fontSize: "small", - width: 200, - textAlign: "center", - margin: "auto", - wordBreak: "break-all", - }} - > - {qrText} - </div> - </Fragment> - )} - </Fragment> - ) : undefined} </FormProvider> <div class="buttons is-right mt-5"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx new file mode 100644 index 000000000..6ab2a2df6 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx @@ -0,0 +1,165 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/exception/AsyncButton.js"; +import { FormProvider } from "../../../components/form/FormProvider.js"; +import { Input } from "../../../components/form/Input.js"; +import { useInstanceContext } from "../../../context/instance.js"; + +interface Props { + instanceId: string; + currentToken: string | undefined; + onClearToken: () => void; + onNewToken: (s: string) => void; + onBack?: () => void; +} + +export function DetailPage({ instanceId, currentToken: oldToken, onBack, onNewToken, onClearToken }: Props): VNode { + type State = { old_token: string; new_token: string; repeat_token: string }; + const [form, setValue] = useState<Partial<State>>({ + old_token: "", + new_token: "", + repeat_token: "", + }); + const { i18n } = useTranslationContext(); + + const hasOldtoken = !!oldToken + const hasInputTheCorrectOldToken = hasOldtoken && oldToken !== form.old_token; + const errors = { + old_token: hasInputTheCorrectOldToken + ? i18n.str`is not the same as the current access token` + : undefined, + new_token: !form.new_token + ? i18n.str`cannot be empty` + : form.new_token === form.old_token + ? i18n.str`cannot be the same as the old token` + : undefined, + repeat_token: + form.new_token !== form.repeat_token + ? i18n.str`is not the same` + : undefined, + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const instance = useInstanceContext(); + + const text = i18n.str`You are updating the access token from instance with id ${instance.id}`; + + async function submitForm() { + if (hasErrors) return; + onNewToken(form.new_token as any) + } + + return ( + <div> + <section class="section"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span class="is-size-4"> + Instace id: <b>{instanceId}</b> + </span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider errors={errors} object={form} valueHandler={setValue}> + {hasOldtoken && ( + <Input<State> + name="old_token" + label={i18n.str`Current access token`} + tooltip={i18n.str`access token currently in use`} + inputType="password" + /> + )} + {!hasInputTheCorrectOldToken && <Fragment> + {hasOldtoken && <Fragment> + <p> + <i18n.Translate> + Clearing the access token will mean public access to the instance. + </i18n.Translate> + </p> + <div class="buttons is-right mt-5"> + <button + disabled={!!hasInputTheCorrectOldToken} + class="button" + onClick={onClearToken} + > + <i18n.Translate>Clear token</i18n.Translate> + </button> + </div> + </Fragment> + } + + <Input<State> + name="new_token" + label={i18n.str`New access token`} + tooltip={i18n.str`next access token to be used`} + inputType="password" + /> + <Input<State> + name="repeat_token" + label={i18n.str`Repeat access token`} + tooltip={i18n.str`confirm the same access token`} + inputType="password" + /> + </Fragment>} + </FormProvider> + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <i18n.Translate>Confirm change</i18n.Translate> + </AsyncButton> + </div> + </div> + <div class="column" /> + </div> + + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx new file mode 100644 index 000000000..d5910361b --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx @@ -0,0 +1,90 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { Loading } from "../../../components/exception/loading.js"; +import { MerchantBackend } from "../../../declaration.js"; +import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; +import { DetailPage } from "./DetailPage.js"; +import { useInstanceContext } from "../../../context/instance.js"; +import { useState } from "preact/hooks"; +import { NotificationCard } from "../../../components/menu/index.js"; +import { Notification } from "../../../utils/types.js"; +import { useBackendContext } from "../../../context/backend.js"; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; + onChange: () => void; + onNotFound: () => VNode; +} + +const PREFIX = "secret-token:" + +export default function Token({ + onLoadError, + onChange, + onUnauthorized, + onNotFound, +}: Props): VNode { + const { i18n } = useTranslationContext(); + + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { clearToken, setNewToken } = useInstanceAPI(); + const { token: rootToken } = useBackendContext(); + const { token: instanceToken, id, admin } = useInstanceContext(); + + const currentToken = !admin ? rootToken : instanceToken + const hasPrefix = currentToken !== undefined && currentToken.startsWith(PREFIX) + return ( + <Fragment> + <NotificationCard notification={notif} /> + <DetailPage + instanceId={id} + currentToken={hasPrefix ? currentToken.substring(PREFIX.length) : currentToken} + onClearToken={async (): Promise<void> => { + try { + await clearToken(); + onChange(); + } catch (error) { + if (error instanceof Error) { + setNotif({ + message: i18n.str`Failed to clear token`, + type: "ERROR", + description: error.message, + }); + } + } + }} + onNewToken={async (newToken): Promise<void> => { + try { + await setNewToken(`secret-token:${newToken}`); + onChange(); + } catch (error) { + if (error instanceof Error) { + setNotif({ + message: i18n.str`Failed to set new token`, + type: "ERROR", + description: error.message, + }); + } + } + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx new file mode 100644 index 000000000..5f0f56f2d --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { DetailPage as TestedComponent } from "./DetailPage.js"; + +export default { + title: "Pages/Token", + component: TestedComponent, +}; + diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx index f218f4ead..25551a031 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx @@ -28,6 +28,7 @@ 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 { @@ -39,7 +40,7 @@ export default function CreateTransfer({ onConfirm, onBack }: Props): VNode { const { informTransfer } = useTransferAPI(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); - const instance = useInstanceDetails(); + const instance = useInstanceBankAccounts(); const accounts = !instance.ok ? [] : instance.data.accounts.map((a) => a.payto_uri); 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 a2e93d598..1c464cbc7 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 @@ -24,6 +24,7 @@ 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; @@ -56,7 +57,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-bank" /> + <i class="mdi mdi-arrow-left-right" /> </span> <i18n.Translate>Transfers</i18n.Translate> </p> @@ -121,6 +122,7 @@ function Table({ hasMoreBefore, }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = useSettings(); return ( <div class="table-container"> {onLoadMoreBefore && ( @@ -175,9 +177,9 @@ function Table({ ? i.execution_time.t_s == "never" ? i18n.str`never` : format( - i.execution_time.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - ) + i.execution_time.t_s * 1000, + datetimeFormatForSettings(settings), + ) : i18n.str`unknown`} </td> <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 29e860342..1bc1673ba 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 @@ -28,6 +28,7 @@ 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; @@ -51,7 +52,7 @@ export default function ListTransfer({ const [position, setPosition] = useState<string | undefined>(undefined); - const instance = useInstanceDetails(); + const instance = useInstanceBankAccounts(); const accounts = !instance.ok ? [] : instance.data.accounts.map((a) => a.payto_uri); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx index 045c96c2c..817a7025c 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx @@ -42,17 +42,15 @@ function createExample<Props>( export const Example = createExample(TestedComponent, { selected: { - accounts: [], name: "name", auth: { method: "external" }, address: {}, + user_type: "business", + use_stefan: true, jurisdiction: {}, - default_max_deposit_fee: "TESTKUDOS:2", - default_max_wire_fee: "TESTKUDOS:1", default_pay_delay: { d_us: 1000 * 1000, //one second }, - default_wire_fee_amortization: 1, default_wire_transfer_delay: { d_us: 1000 * 1000, //one second }, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx index 547b40f07..a1c608f15 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx @@ -19,7 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -29,10 +28,8 @@ import { FormProvider, } from "../../../components/form/FormProvider.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; -import { UpdateTokenModal } from "../../../components/modal/index.js"; import { useInstanceContext } from "../../../context/instance.js"; import { MerchantBackend } from "../../../declaration.js"; -import { PAYTO_REGEX } from "../../../utils/constants.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; type Entity = MerchantBackend.Instances.InstanceReconfigurationMessage & { @@ -53,23 +50,23 @@ interface Props { function convert( from: MerchantBackend.Instances.QueryInstancesResponse, ): Entity { - const { accounts: qAccounts, ...rest } = from; - const accounts = qAccounts - .filter((a) => a.active) - .map( - (a) => - ({ - payto_uri: a.payto_uri, - credit_facade_url: a.credit_facade_url, - credit_facade_credentials: a.credit_facade_credentials, - } as MerchantBackend.Instances.MerchantBankAccount), - ); + const { ...rest } = from; + // const accounts = qAccounts + // .filter((a) => a.active) + // .map( + // (a) => + // ({ + // payto_uri: a.payto_uri, + // credit_facade_url: a.credit_facade_url, + // credit_facade_credentials: a.credit_facade_credentials, + // } as MerchantBackend.Instances.MerchantBankAccount), + // ); const defaults = { - default_wire_fee_amortization: 1, + use_stefan: false, default_pay_delay: { d_us: 2 * 1000 * 1000 * 60 * 60 }, //two hours default_wire_transfer_delay: { d_us: 2 * 1000 * 1000 * 60 * 60 * 2 }, //two hours }; - return { ...defaults, ...rest, accounts }; + return { ...defaults, ...rest }; } function getTokenValuePart(t?: string): string | undefined { @@ -85,21 +82,21 @@ export function UpdatePage({ selected, onBack, }: Props): VNode { - const { id, token } = useInstanceContext(); - const currentTokenValue = getTokenValuePart(token); - - function updateToken(token: string | undefined | null) { - const value = - token && token.startsWith("secret-token:") - ? token.substring("secret-token:".length) - : token; - - if (!token) { - onChangeAuth({ method: "external" }); - } else { - onChangeAuth({ method: "token", token: `secret-token:${value}` }); - } - } + const { id } = useInstanceContext(); + // const currentTokenValue = getTokenValuePart(token); + + // function updateToken(token: string | undefined | null) { + // const value = + // token && token.startsWith("secret-token:") + // ? token.substring("secret-token:".length) + // : token; + + // if (!token) { + // onChangeAuth({ method: "external" }); + // } else { + // onChangeAuth({ method: "token", token: `secret-token:${value}` }); + // } + // } const [value, valueHandler] = useState<Partial<Entity>>(convert(selected)); @@ -110,35 +107,7 @@ export function UpdatePage({ 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_max_deposit_fee: !value.default_max_deposit_fee - ? i18n.str`required` - : !Amounts.parse(value.default_max_deposit_fee) - ? i18n.str`invalid format` - : undefined, - default_max_wire_fee: !value.default_max_wire_fee - ? i18n.str`required` - : !Amounts.parse(value.default_max_wire_fee) - ? i18n.str`invalid format` - : undefined, - default_wire_fee_amortization: - value.default_wire_fee_amortization === undefined - ? i18n.str`required` - : isNaN(value.default_wire_fee_amortization) - ? i18n.str`is not a number` - : value.default_wire_fee_amortization < 1 - ? i18n.str`must be 1 or greater` + ? i18n.str`should be business or individual` : undefined, default_pay_delay: !value.default_pay_delay ? i18n.str`required` @@ -163,10 +132,11 @@ export function UpdatePage({ const hasErrors = Object.keys(errors).some( (k) => (errors as any)[k] !== undefined, ); + const submit = async (): Promise<void> => { await onUpdate(value as Entity); }; - const [active, setActive] = useState(false); + // const [active, setActive] = useState(false); return ( <div> @@ -181,7 +151,7 @@ export function UpdatePage({ </span> </div> </div> - <div class="level-right"> + {/* <div class="level-right"> <div class="level-item"> <h1 class="title"> <button @@ -200,33 +170,11 @@ export function UpdatePage({ </button> </h1> </div> - </div> + </div> */} </div> </div> </section> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - {active && ( - <UpdateTokenModal - oldToken={currentTokenValue} - onCancel={() => { - setActive(false); - }} - onClear={() => { - updateToken(null); - setActive(false); - }} - onConfirm={(newToken) => { - updateToken(newToken); - setActive(false); - }} - /> - )} - </div> - <div class="column" /> - </div> <hr /> <div class="columns"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/Create.stories.tsx new file mode 100644 index 000000000..56762db7b --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/Create.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, FunctionalComponent } from "preact"; +import { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/Validators/Create", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatePage.tsx new file mode 100644 index 000000000..bdc86d226 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatePage.tsx @@ -0,0 +1,195 @@ +/* + 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"; +import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; +import { isBase32RFC3548Charset, randomBase32Key } from "../../../../utils/crypto.js"; +import { QR } from "../../../../components/exception/QR.js"; +import { useInstanceContext } from "../../../../context/instance.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` : + !isBase32RFC3548Charset(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_description: !state.otp_description ? i18n.str`required` + : !/[a-zA-Z0-9]*/.test(state.otp_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_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> + 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()} + addonAfter={ + <span class="icon"> + {showKey ? ( + <i class="mdi mdi-eye" /> + ) : ( + <i class="mdi mdi-eye-off" /> + )} + </span> + } + side={ + <span style={{ display: "flex" }}> + <button + data-tooltip={i18n.str`generate random secret key`} + class="button is-info mr-3" + onClick={(e) => { + setState((s) => ({ ...s, otp_key: randomBase32Key() })); + }} + > + <i18n.Translate>random</i18n.Translate> + </button> + <button + data-tooltip={ + showKey + ? i18n.str`show secret key` + : i18n.str`hide secret key` + } + class="button is-info mr-3" + onClick={(e) => { + setShowKey(!showKey); + }} + > + {showKey ? ( + <i18n.Translate>hide</i18n.Translate> + ) : ( + <i18n.Translate>show</i18n.Translate> + )} + </button> + </span> + } + /> + </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/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx new file mode 100644 index 000000000..3ad3cb3a3 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx @@ -0,0 +1,104 @@ +/* + 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 { useBackendContext } from "../../../../context/backend.js"; +import { useInstanceContext } from "../../../../context/instance.js"; +import { MerchantBackend } from "../../../../declaration.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 backend = useBackendContext(); + const { id: instanceId } = useInstanceContext(); + const issuer = new URL(backend.url).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_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/merchant-backoffice-ui/src/paths/instance/validators/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/index.tsx new file mode 100644 index 000000000..648846793 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/index.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 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/merchant-backoffice-ui/src/paths/instance/validators/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/List.stories.tsx new file mode 100644 index 000000000..3aa491c53 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/List.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { FunctionalComponent, h } from "preact"; +import { ListPage as TestedComponent } from "./ListPage.js"; + +export default { + title: "Pages/Validators/List", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/ListPage.tsx new file mode 100644 index 000000000..4efee9781 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/ListPage.tsx @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from "preact"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { CardTable } from "./Table.js"; + +export interface Props { + devices: MerchantBackend.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/merchant-backoffice-ui/src/paths/instance/validators/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/Table.tsx new file mode 100644 index 000000000..b639a6134 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/Table.tsx @@ -0,0 +1,213 @@ +/* + 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"> + {onLoadMoreBefore && ( + <button + class="button is-fullwidth" + data-tooltip={i18n.str`load more devices before the first one`} + disabled={!hasMoreBefore} + 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> + {onLoadMoreAfter && ( + <button + class="button is-fullwidth" + data-tooltip={i18n.str`load more devices after the last one`} + disabled={!hasMoreAfter} + 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/merchant-backoffice-ui/src/paths/instance/validators/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/index.tsx new file mode 100644 index 000000000..8837c848b --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/index.tsx @@ -0,0 +1,106 @@ +/* + 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 ListValidators({ + onUnauthorized, + onLoadError, + onCreate, + onSelect, + onNotFound, +}: Props): VNode { + const [position, setPosition] = useState<string | undefined>(undefined); + const { i18n } = useTranslationContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { 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/merchant-backoffice-ui/src/paths/instance/validators/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/Update.stories.tsx new file mode 100644 index 000000000..fcb77b820 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/Update.stories.tsx @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, FunctionalComponent } from "preact"; +import { UpdatePage as TestedComponent } from "./UpdatePage.js"; + +export default { + title: "Pages/Validators/Update", + component: TestedComponent, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/UpdatePage.tsx new file mode 100644 index 000000000..585c12e11 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/UpdatePage.tsx @@ -0,0 +1,185 @@ +/* + 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 { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; +import { randomBase32Key } from "../../../../utils/crypto.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_description" + label={i18n.str`Description`} + tooltip={i18n.str`dddd`} + /> + <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()} + addonAfter={ + <span class="icon"> + {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> : + <span style={{ display: "flex" }}> + <button + data-tooltip={i18n.str`generate random secret key`} + class="button is-info mr-3" + onClick={(e) => { + setState((s) => ({ ...s, otp_key: randomBase32Key() })); + }} + > + <i18n.Translate>random</i18n.Translate> + </button> + <button + data-tooltip={ + showKey + ? i18n.str`show secret key` + : i18n.str`hide secret key` + } + class="button is-info mr-3" + onClick={(e) => { + setShowKey(!showKey); + }} + > + {showKey ? ( + <i18n.Translate>hide</i18n.Translate> + ) : ( + <i18n.Translate>show</i18n.Translate> + )} + </button> + </span> + } + /> + </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/merchant-backoffice-ui/src/paths/instance/validators/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/index.tsx new file mode 100644 index 000000000..9a27ccfee --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/index.tsx @@ -0,0 +1,102 @@ +/* + 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 { Notification } from "../../../../utils/types.js"; +import { UpdatePage } from "./UpdatePage.js"; +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { useOtpDeviceAPI, useOtpDeviceDetails } from "../../../../hooks/otp.js"; + +export type Entity = MerchantBackend.OTP.OtpDevicePatchDetails & WithId; + +interface Props { + onBack?: () => void; + onConfirm: () => void; + onUnauthorized: () => VNode; + onNotFound: () => VNode; + onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; + vid: string; +} +export default function UpdateValidator({ + vid, + onConfirm, + onBack, + onUnauthorized, + onNotFound, + onLoadError, +}: Props): VNode { + const { updateOtpDevice } = useOtpDeviceAPI(); + const result = useOtpDeviceDetails(vid); + 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 + device={{ + id: vid, + otp_algorithm: result.data.otp_algorithm, + otp_description: result.data.device_description, + otp_key: undefined, + otp_ctr: result.data.otp_ctr + }} + onBack={onBack} + onUpdate={(data) => { + return updateOtpDevice(vid, data) + .then(onConfirm) + .catch((error) => { + setNotif({ + message: i18n.str`could not update template`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx index fd7b08875..124ced1f1 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx @@ -81,9 +81,6 @@ export function CardTable({ instances={webhooks} onDelete={onDelete} onSelect={onSelect} - onNewOrder={(d) => { - console.log("test", d); - }} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} onLoadMoreAfter={onLoadMoreAfter} @@ -104,7 +101,6 @@ interface TableProps { rowSelection: string[]; instances: Entity[]; onDelete: (e: Entity) => void; - onNewOrder: (e: Entity) => void; onSelect: (e: Entity) => void; rowSelectionHandler: StateUpdater<string[]>; onLoadMoreBefore?: () => void; @@ -122,7 +118,6 @@ function Table({ instances, onLoadMoreAfter, onDelete, - onNewOrder, onSelect, onLoadMoreBefore, hasMoreAfter, diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx index 128450553..0d514f2df 100644 --- a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx @@ -1,10 +1,10 @@ -import { VNode, h } from "preact"; -import { LangSelector } from "../../components/menu/LangSelector.js"; 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 { LangSelector } from "../../components/menu/LangSelector.js"; import { Settings, useSettings } from "../../hooks/useSettings.js"; -import { FormErrors, FormProvider } from "../../components/form/FormProvider.js"; -import { useState } from "preact/hooks"; function getBrowserLang(): string | undefined { if (typeof window === "undefined") return undefined; @@ -24,7 +24,11 @@ export function Settings(): VNode { function valueHandler(s: (d: Partial<Settings>) => Partial<Settings>): void { const next = s(value) - updateValue("advanceOrderMode", next.advanceOrderMode ?? false) + const v: Settings = { + advanceOrderMode: next.advanceOrderMode ?? false, + dateFormat: next.dateFormat ?? "ymd" + } + updateValue(v) } return <div> @@ -32,41 +36,64 @@ export function Settings(): VNode { <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" style={{ width: 200 }}> - <i18n.Translate>Language</i18n.Translate> - <span class="icon has-tooltip-right" data-tooltip={"Force language setting instance of taking the browser"}> - <i class="mdi mdi-information" /> - </span> - </label> - </div> - <div class="field has-addons"> - <LangSelector /> - - {borwserLang !== undefined && <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-3" - onClick={(e) => { - update(borwserLang.substring(0, 2)) + <div> + + <FormProvider<Settings> + name="settings" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Language</i18n.Translate> + <span class="icon has-tooltip-right" data-tooltip={"Force language setting instance of taking the browser"}> + <i class="mdi mdi-information" /> + </span> + </label> + </div> + <div class="field field-body has-addons is-flex-grow-3"> + <LangSelector /> + + {borwserLang !== undefined && <button + data-tooltip={i18n.str`generate random secret key`} + class="button is-info mr-2" + onClick={(e) => { + 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" }} - > - <i18n.Translate>Set default</i18n.Translate> - </button>} - </div> + values={[ + "ymd", + "mdy", + "dmy", + ]} + tooltip={i18n.str`how the date is going to be displayed`} + /> + </FormProvider> </div> - <FormProvider<Settings> - name="settings" - errors={errors} - object={value} - valueHandler={valueHandler} - > - <InputToggle<Settings> - label={i18n.str`Advance order creation`} - tooltip={i18n.str`Shows more options in the order creation form`} - name="advanceOrderMode" - /> - </FormProvider> </div> diff --git a/packages/merchant-backoffice-ui/src/schemas/index.ts b/packages/merchant-backoffice-ui/src/schemas/index.ts index 149761c55..4be77595b 100644 --- a/packages/merchant-backoffice-ui/src/schemas/index.ts +++ b/packages/merchant-backoffice-ui/src/schemas/index.ts @@ -123,7 +123,7 @@ export const InstanceSchema = yup.object().shape({ export const InstanceUpdateSchema = InstanceSchema.clone().omit(["id"]); export const InstanceCreateSchema = InstanceSchema.clone(); -export const AuthorizeTipSchema = yup.object().shape({ +export const AuthorizeRewardSchema = yup.object().shape({ justification: yup.string().required(), amount: yup .string() @@ -161,7 +161,7 @@ export const OrderCreateSchema = yup.object().shape({ currencyGreaterThan0, ), }), - extra: yup.string().test("extra", "is not a JSON format", stringIsValidJSON), + // extra: yup.object().test("extra", "is not a JSON format", stringIsValidJSON), payments: yup .object() .required() diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx index b32eb831a..6ade0718a 100644 --- a/packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx @@ -16,7 +16,6 @@ import { Amounts, - BackupBackupProviderTerms, canonicalizeBaseUrl, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -41,6 +40,12 @@ interface Props { onBack: () => Promise<void>; } +interface BackupBackupProviderTerms { + annual_fee: string; + storage_limit_in_megabytes: number; + supported_protocol_version: string; +} + export function ProviderAddPage({ onBack }: Props): VNode { const [verifying, setVerifying] = useState< | { url: string; name: string; provider: BackupBackupProviderTerms } |