commit a427958be6d32ce8d907c885d577c8a05ef450a0 parent 8aa9ce6d20b41b7eb9b438a56ccd34cb0da35f80 Author: Sebastian <sebasjm@gmail.com> Date: Fri, 22 Mar 2024 09:03:27 -0300 wip Diffstat:
12 files changed, 814 insertions(+), 1059 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/AdminRoutes.tsx b/packages/merchant-backoffice-ui/src/AdminRoutes.tsx @@ -17,7 +17,7 @@ import { h, VNode } from "preact"; import { Router, route, Route } from "preact-router"; import InstanceCreatePage from "./paths/admin/create/index.js"; import InstanceListPage from "./paths/admin/list/index.js"; -import { InstancePaths } from "./Rounting.js"; +import { InstancePaths } from "./Routing.js"; export enum AdminPaths { list_instances = "/instances", diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx @@ -26,7 +26,7 @@ import { TalerWalletIntegrationBrowserProvider, TranslationProvider } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { SWRConfig } from "swr"; import { Routing } from "./Routing.js"; diff --git a/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx b/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx @@ -1,174 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { createHashHistory } from "history"; -import { Fragment, VNode, h } from "preact"; -import { Route, Router, route } from "preact-router"; -import { useState } from "preact/hooks"; -import { InstanceRoutes } from "./Rounting.js"; -import { - NotConnectedAppMenu, - NotYetReadyAppMenu, - NotificationCard, -} from "./components/menu/index.js"; -import { useBackendContext } from "./context/backend.js"; -import { LoginToken } from "./declaration.js"; -import { useBackendInstancesTestForAdmin } from "./hooks/backend.js"; -import { LoginPage } from "./paths/login/index.js"; -import { Settings } from "./paths/settings/index.js"; -import { INSTANCE_ID_LOOKUP } from "./utils/constants.js"; - -/** - * Check if admin against /management/instances - * @returns - */ -export function ApplicationReadyRoutes(): VNode { - const { i18n } = useTranslationContext(); - const [unauthorized, setUnauthorized] = useState(false) - const { - url: backendURL, - updateToken, - alreadyTriedLogin, - } = useBackendContext(); - - function updateLoginStatus(token: LoginToken | undefined) { - updateToken(token) - setUnauthorized(false) - } - const result = useBackendInstancesTestForAdmin(); - - const clearTokenAndGoToRoot = () => { - route("/"); - }; - const [showSettings, setShowSettings] = useState(false) - const unauthorizedAdmin = !result.loading - && !result.ok - && result.type === ErrorType.CLIENT - && result.status === HttpStatusCode.Unauthorized; - - if (!alreadyTriedLogin && !result.ok) { - return ( - <Fragment> - <NotConnectedAppMenu title="Welcome!" /> - <LoginPage onConfirm={updateToken} /> - </Fragment> - ); - } - - if (showSettings) { - return <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> - <Settings onClose={() => setShowSettings(false)} /> - </Fragment> - } - - if (result.loading) { - return <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Loading..." isPasswordOk={false} />; - } - - let admin = result.ok || unauthorizedAdmin; - let instanceNameByBackendURL: string | undefined; - - if (!admin) { - // * the testing against admin endpoint failed and it's not - // an authorization problem - // * merchant backend will return this SPA under the main - // endpoint or /instance/<id> endpoint - // => trying to infer the instance id - const path = new URL(backendURL).pathname; - const match = INSTANCE_ID_LOOKUP.exec(path); - if (!match || !match[1]) { - // this should be rare because - // query to /config is ok but the URL - // does not match our pattern - return ( - <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Error" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> - <NotificationCard - notification={{ - message: i18n.str`Couldn't access the server.`, - description: i18n.str`Could not infer instance id from url ${backendURL}`, - type: "ERROR", - }} - /> - {/* <ConnectionPage onConfirm={changeBackend} /> */} - </Fragment> - ); - } - - instanceNameByBackendURL = match[1]; - } - - if (unauthorized || unauthorizedAdmin) { - return <Fragment> - <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> - <NotificationCard - notification={{ - message: i18n.str`Access denied`, - description: i18n.str`Check your token is valid`, - type: "ERROR", - }} - /> - <LoginPage onConfirm={updateLoginStatus} /> - </Fragment> - } - - const history = createHashHistory(); - return ( - <Router history={history}> - <Route - default - component={DefaultMainRoute} - admin={admin} - onUnauthorized={() => setUnauthorized(true)} - onLoginPass={() => { - setUnauthorized(false) - }} - instanceNameByBackendURL={instanceNameByBackendURL} - /> - </Router> - ); -} - -function DefaultMainRoute({ - instance, - admin, - onUnauthorized, - onLoginPass, - instanceNameByBackendURL, - url, //from preact-router -}: any): VNode { - const [instanceName, setInstanceName] = useState( - instanceNameByBackendURL || instance || "default", - ); - - return ( - <InstanceRoutes - admin={admin} - path={url} - onUnauthorized={onUnauthorized} - onLoginPass={onLoginPass} - id={instanceName} - setInstanceName={setInstanceName} - /> - ); -} diff --git a/packages/merchant-backoffice-ui/src/Rounting.tsx b/packages/merchant-backoffice-ui/src/Rounting.tsx @@ -1,802 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; -import { - ErrorType, - HttpError, - urlPattern, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { Fragment, FunctionComponent, VNode, h } from "preact"; -import { Route, Router, route } from "preact-router"; -import { useEffect, useErrorBoundary, useMemo, useState } from "preact/hooks"; -import { Loading } from "./components/exception/loading.js"; -import { - Menu, - NotConnectedAppMenu, - NotificationCard, -} from "./components/menu/index.js"; -import { InstanceContextProvider } from "./context/instance.js"; -import { LoginToken, MerchantBackend } from "./declaration.js"; -import { useInstanceBankAccounts } from "./hooks/bank.js"; -import { useInstanceKYCDetails } from "./hooks/instance.js"; -import { dateFormatForSettings, usePreference } from "./hooks/preference.js"; -import InstanceCreatePage from "./paths/admin/create/index.js"; -import InstanceListPage from "./paths/admin/list/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 ListKYCPage from "./paths/instance/kyc/list/index.js"; -import OrderCreatePage from "./paths/instance/orders/create/index.js"; -import OrderDetailsPage from "./paths/instance/orders/details/index.js"; -import OrderListPage from "./paths/instance/orders/list/index.js"; -import ValidatorCreatePage from "./paths/instance/otp_devices/create/index.js"; -import ValidatorListPage from "./paths/instance/otp_devices/list/index.js"; -import ValidatorUpdatePage from "./paths/instance/otp_devices/update/index.js"; -import 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 TemplateCreatePage from "./paths/instance/templates/create/index.js"; -import TemplateListPage from "./paths/instance/templates/list/index.js"; -import TemplateQrPage from "./paths/instance/templates/qr/index.js"; -import TemplateUpdatePage from "./paths/instance/templates/update/index.js"; -import TemplateUsePage from "./paths/instance/templates/use/index.js"; -import TokenPage from "./paths/instance/token/index.js"; -import TransferCreatePage from "./paths/instance/transfers/create/index.js"; -import TransferListPage from "./paths/instance/transfers/list/index.js"; -import InstanceUpdatePage, { - AdminUpdate as InstanceAdminUpdatePage, - Props as InstanceUpdatePageProps, -} from "./paths/instance/update/index.js"; -import 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 { LoginPage } from "./paths/login/index.js"; -import NotFoundPage from "./paths/notfound/index.js"; -import { Settings } from "./paths/settings/index.js"; -import { Notification } from "./utils/types.js"; -import { useSessionState } from "./hooks/session.js"; - -export enum InstancePaths { - error = "/error", - settings = "/settings", - token = "/token", - - bank_list = "/bank", - bank_update = "/bank/:bid/update", - bank_new = "/bank/new", - - inventory_list = "/inventory", - inventory_update = "/inventory/:pid/update", - inventory_new = "/inventory/new", - - order_list = "/orders", - order_new = "/order/new", - order_details = "/order/:oid/details", - - reserves_list = "/reserves", - reserves_details = "/reserves/:rid/details", - reserves_new = "/reserves/new", - - kyc = "/kyc", - - transfers_list = "/transfers", - transfers_new = "/transfer/new", - - templates_list = "/templates", - templates_update = "/templates/:tid/update", - templates_new = "/templates/new", - templates_use = "/templates/:tid/use", - templates_qr = "/templates/:tid/qr", - - webhooks_list = "/webhooks", - webhooks_update = "/webhooks/:tid/update", - webhooks_new = "/webhooks/new", - - otp_devices_list = "/otp-devices", - otp_devices_update = "/otp-devices/:vid/update", - otp_devices_new = "/otp-devices/new", - - interface = "/interface", -} - -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; - -export enum AdminPaths { - list_instances = "/instances", - new_instance = "/instance/new", - update_instance = "/instance/:id/update", -} - -export interface Props {} - -export const privatePages = { - home: urlPattern(/\/home/, () => "#/home"), - go: urlPattern(/\/home/, () => "#/home"), -}; -export const publicPages = { - home: urlPattern(/\/home/, () => "#/home"), - go: urlPattern(/\/home/, () => "#/home"), -}; - -export function Routing(_p: Props): VNode { - const { i18n } = useTranslationContext(); - const { state } = useSessionState(); - const admin = state.isAdmin; - const id = state.instance; - - type GlobalNotifState = - | (Notification & { to: string | undefined }) - | undefined; - const [globalNotification, setGlobalNotification] = - useState<GlobalNotifState>(undefined); - - // const changeToken = (token?: LoginToken) => { - // if (admin) { - // updateToken(token); - // } else { - // updateDefaultToken(token); - // } - // onLoginPass(); - // }; - - const [error] = useErrorBoundary(); - - // const value = useMemo( - // () => ({ id, token, admin, changeToken }), - // [id, token, admin], - // ); - - const instance = useInstanceBankAccounts(); - const accounts = !instance.ok ? undefined : instance.data.accounts; - - function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) { - return function ServerErrorRedirectToImpl( - error: HttpError<MerchantBackend.ErrorDetail>, - ) { - if (error.type === ErrorType.TIMEOUT) { - setGlobalNotification({ - message: i18n.str`The request to the backend take too long and was cancelled`, - description: i18n.str`Diagnostic from ${error.info.url} is "${error.message}"`, - type: "ERROR", - to, - }); - } else { - setGlobalNotification({ - message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, - description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, - details: - error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER - ? error.payload.detail - : undefined, - type: "ERROR", - to, - }); - } - return <Redirect to={to} />; - }; - } - - // const LoginPageAccessDeniend = onUnauthorized - const LoginPageAccessDenied = () => { - return ( - <Fragment> - <NotificationCard - notification={{ - message: i18n.str`Access denied`, - description: i18n.str`Session expired or password changed.`, - type: "ERROR", - }} - /> - <LoginPage /> - </Fragment> - ); - }; - - function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<unknown>) { - return function IfAdminCreateDefaultOrImpl(props?: T) { - if (admin && id === "default") { - return ( - <Fragment> - <NotificationCard - notification={{ - message: i18n.str`No 'default' instance configured yet.`, - description: i18n.str`Create a 'default' instance to begin using the merchant backoffice.`, - type: "INFO", - }} - /> - <InstanceCreatePage - forceId="default" - onConfirm={() => { - route(InstancePaths.bank_list); - }} - /> - </Fragment> - ); - } - if (props) { - return <Next {...props} />; - } - return <Next />; - }; - } - - const clearTokenAndGoToRoot = () => { - route("/"); - // clear all tokens - updateToken(undefined); - updateDefaultToken(undefined); - }; - - if (state.status === "loggedOut" || state.status === "expired") { - return ( - <Fragment> - <NotConnectedAppMenu title="Welcome!" /> - <LoginPage /> - </Fragment> - ); - } - - if (accounts !== undefined && !admin && accounts.length < 1) { - return ( - <Fragment> - <Menu - instance={id} - admin={admin} - onShowSettings={() => { - route(InstancePaths.interface); - }} - path={path} - onLogout={clearTokenAndGoToRoot} - setInstanceName={setInstanceName} - isPasswordOk={defaultToken !== undefined} - /> - <NotificationCard - notification={{ - type: "INFO", - message: i18n.str`You need to associate a bank account to receive revenue.`, - description: i18n.str`Without this the merchant backend will refuse to create new orders.`, - }} - /> - <BankAccountCreatePage onConfirm={() => {}} /> - </Fragment> - ); - } - - return ( - <Fragment> - <Menu - instance={id} - admin={admin} - onShowSettings={() => { - route(InstancePaths.interface); - }} - path={path} - onLogout={clearTokenAndGoToRoot} - setInstanceName={setInstanceName} - isPasswordOk={defaultToken !== undefined} - /> - <KycBanner /> - <NotificationCard notification={globalNotification} /> - {error && ( - <NotificationCard - notification={{ - message: "Internal error, please repot", - type: "ERROR", - description: ( - <pre> - { - (error instanceof Error - ? error.stack - : String(error)) as TranslatedString - } - </pre> - ), - }} - /> - )} - - <Router - onChange={(e) => { - const movingOutFromNotification = - globalNotification && e.url !== globalNotification.to; - if (movingOutFromNotification) { - setGlobalNotification(undefined); - } - }} - > - <Route path="/" component={Redirect} to={InstancePaths.order_list} /> - {/** - * Admin pages - */} - {admin && ( - <Route - path={AdminPaths.list_instances} - component={InstanceListPage} - onCreate={() => { - route(AdminPaths.new_instance); - }} - onUpdate={(id: string): void => { - route(`/instance/${id}/update`); - }} - setInstanceName={setInstanceName} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.error)} - /> - )} - {admin && ( - <Route - path={AdminPaths.new_instance} - component={InstanceCreatePage} - onBack={() => route(AdminPaths.list_instances)} - onConfirm={() => { - route(InstancePaths.order_list); - }} - /> - )} - {admin && ( - <Route - path={AdminPaths.update_instance} - component={AdminInstanceUpdatePage} - onBack={() => route(AdminPaths.list_instances)} - onConfirm={() => { - route(AdminPaths.list_instances); - }} - onUpdateError={ServerErrorRedirectTo(AdminPaths.list_instances)} - onLoadError={ServerErrorRedirectTo(AdminPaths.list_instances)} - onNotFound={NotFoundPage} - /> - )} - {/** - * Update instance page - */} - <Route - path={InstancePaths.settings} - component={InstanceUpdatePage} - onBack={() => { - route(`/`); - }} - onConfirm={() => { - route(`/`); - }} - onUpdateError={noop} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.error)} - /> - {/** - * Update instance page - */} - <Route - path={InstancePaths.token} - component={TokenPage} - onChange={() => { - route(`/`); - }} - onCancel={() => { - route(InstancePaths.order_list); - }} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.error)} - /> - {/** - * Inventory pages - */} - <Route - path={InstancePaths.inventory_list} - component={ProductListPage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - onCreate={() => { - route(InstancePaths.inventory_new); - }} - onSelect={(id: string) => { - route(InstancePaths.inventory_update.replace(":pid", id)); - }} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - /> - <Route - path={InstancePaths.inventory_update} - component={ProductUpdatePage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.inventory_list)} - onConfirm={() => { - route(InstancePaths.inventory_list); - }} - onBack={() => { - route(InstancePaths.inventory_list); - }} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - /> - <Route - path={InstancePaths.inventory_new} - component={ProductCreatePage} - onConfirm={() => { - route(InstancePaths.inventory_list); - }} - onBack={() => { - route(InstancePaths.inventory_list); - }} - /> - {/** - * Bank pages - */} - <Route - path={InstancePaths.bank_list} - component={BankAccountListPage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - 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.inventory_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 - path={InstancePaths.order_list} - component={OrderListPage} - onCreate={() => { - route(InstancePaths.order_new); - }} - onSelect={(id: string) => { - route(InstancePaths.order_details.replace(":oid", id)); - }} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - /> - <Route - path={InstancePaths.order_details} - component={OrderDetailsPage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.order_list)} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onBack={() => { - route(InstancePaths.order_list); - }} - /> - <Route - path={InstancePaths.order_new} - component={OrderCreatePage} - onConfirm={(orderId: string) => { - route(InstancePaths.order_details.replace(":oid", orderId)); - }} - onBack={() => { - route(InstancePaths.order_list); - }} - /> - {/** - * Transfer pages - */} - <Route - path={InstancePaths.transfers_list} - component={TransferListPage} - onUnauthorized={LoginPageAccessDenied} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - onCreate={() => { - route(InstancePaths.transfers_new); - }} - /> - <Route - path={InstancePaths.transfers_new} - component={TransferCreatePage} - onConfirm={() => { - route(InstancePaths.transfers_list); - }} - onBack={() => { - route(InstancePaths.transfers_list); - }} - /> - {/** - * Webhooks pages - */} - <Route - path={InstancePaths.webhooks_list} - component={WebhookListPage} - onUnauthorized={LoginPageAccessDenied} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - onCreate={() => { - route(InstancePaths.webhooks_new); - }} - onSelect={(id: string) => { - route(InstancePaths.webhooks_update.replace(":tid", id)); - }} - /> - <Route - path={InstancePaths.webhooks_update} - component={WebhookUpdatePage} - onConfirm={() => { - route(InstancePaths.webhooks_list); - }} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.webhooks_list)} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onBack={() => { - route(InstancePaths.webhooks_list); - }} - /> - <Route - path={InstancePaths.webhooks_new} - component={WebhookCreatePage} - onConfirm={() => { - route(InstancePaths.webhooks_list); - }} - onBack={() => { - route(InstancePaths.webhooks_list); - }} - /> - {/** - * Validator pages - */} - <Route - path={InstancePaths.otp_devices_list} - component={ValidatorListPage} - onUnauthorized={LoginPageAccessDenied} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - onCreate={() => { - route(InstancePaths.otp_devices_new); - }} - onSelect={(id: string) => { - route(InstancePaths.otp_devices_update.replace(":vid", id)); - }} - /> - <Route - path={InstancePaths.otp_devices_update} - component={ValidatorUpdatePage} - onConfirm={() => { - route(InstancePaths.otp_devices_list); - }} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.otp_devices_list)} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onBack={() => { - route(InstancePaths.otp_devices_list); - }} - /> - <Route - path={InstancePaths.otp_devices_new} - component={ValidatorCreatePage} - onConfirm={() => { - route(InstancePaths.otp_devices_list); - }} - onBack={() => { - route(InstancePaths.otp_devices_list); - }} - /> - {/** - * Templates pages - */} - <Route - path={InstancePaths.templates_list} - component={TemplateListPage} - onUnauthorized={LoginPageAccessDenied} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} - onCreate={() => { - route(InstancePaths.templates_new); - }} - onNewOrder={(id: string) => { - route(InstancePaths.templates_use.replace(":tid", id)); - }} - onQR={(id: string) => { - route(InstancePaths.templates_qr.replace(":tid", id)); - }} - onSelect={(id: string) => { - route(InstancePaths.templates_update.replace(":tid", id)); - }} - /> - <Route - path={InstancePaths.templates_update} - component={TemplateUpdatePage} - onConfirm={() => { - route(InstancePaths.templates_list); - }} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.templates_list)} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onBack={() => { - route(InstancePaths.templates_list); - }} - /> - <Route - path={InstancePaths.templates_new} - component={TemplateCreatePage} - onConfirm={() => { - route(InstancePaths.templates_list); - }} - onBack={() => { - route(InstancePaths.templates_list); - }} - /> - <Route - path={InstancePaths.templates_use} - component={TemplateUsePage} - onOrderCreated={(id: string) => { - route(InstancePaths.order_details.replace(":oid", id)); - }} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.templates_list)} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onBack={() => { - route(InstancePaths.templates_list); - }} - /> - <Route - path={InstancePaths.templates_qr} - component={TemplateQrPage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={ServerErrorRedirectTo(InstancePaths.templates_list)} - onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onBack={() => { - route(InstancePaths.templates_list); - }} - /> - - <Route path={InstancePaths.kyc} component={ListKYCPage} /> - <Route path={InstancePaths.interface} component={Settings} /> - {/** - * Example pages - */} - <Route path="/loading" component={Loading} /> - <Route default component={NotFoundPage} /> - </Router> - </Fragment> - ); -} - -export function Redirect({ to }: { to: string }): null { - useEffect(() => { - route(to, true); - }); - return null; -} - -function AdminInstanceUpdatePage({ - id, - ...rest -}: { id: string } & InstanceUpdatePageProps): VNode { - const { i18n } = useTranslationContext(); - - return ( - <Fragment> - <InstanceAdminUpdatePage - {...rest} - instanceId={id} - onLoadError={(error: HttpError<MerchantBackend.ErrorDetail>) => { - const notif = - error.type === ErrorType.TIMEOUT - ? { - message: i18n.str`The request to the backend take too long and was cancelled`, - description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, - type: "ERROR" as const, - } - : { - message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, - description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, - details: - error.type === ErrorType.CLIENT || - error.type === ErrorType.SERVER - ? error.payload.detail - : undefined, - type: "ERROR" as const, - }; - return ( - <Fragment> - <NotificationCard notification={notif} /> - <LoginPage /> - </Fragment> - ); - }} - onUnauthorized={() => { - return ( - <Fragment> - <NotificationCard - notification={{ - message: i18n.str`Access denied`, - description: i18n.str`The access token provided is invalid`, - type: "ERROR", - }} - /> - <LoginPage /> - </Fragment> - ); - }} - /> - </Fragment> - ); -} - -function KycBanner(): VNode { - const kycStatus = useInstanceKYCDetails(); - const { i18n } = useTranslationContext(); - // const today = format(new Date(), dateFormatForSettings(settings)); - const [prefs, updatePref] = usePreference(); - - const now = AbsoluteTime.now(); - - const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; - - const hidden = AbsoluteTime.cmp(now, prefs.hideKycUntil) < 1; - if (hidden || !needsToBeShown) return <Fragment />; - - const oneDay = { d_ms: 1000 * 60 * 60 * 24 }; - const tomorrow = AbsoluteTime.addDuration(now, oneDay); - - return ( - <NotificationCard - notification={{ - type: "WARN", - message: "KYC verification needed", - description: ( - <div> - <p> - Some transfer are on hold until a KYC process is completed. Go to - the KYC section in the left panel for more information - </p> - <div class="buttons is-right"> - <button - class="button" - onClick={() => updatePref("hideKycUntil", tomorrow)} - > - <i18n.Translate>Hide for today</i18n.Translate> - </button> - </div> - </div> - ), - }} - /> - ); -} diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -0,0 +1,758 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import { + ErrorType, + HttpError, + urlPattern, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, FunctionComponent, VNode, h } from "preact"; +import { Route, Router, route } from "preact-router"; +import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import { Loading } from "./components/exception/loading.js"; +import { + Menu, + NotConnectedAppMenu, + NotificationCard, +} from "./components/menu/index.js"; +import { MerchantBackend } from "./declaration.js"; +import { useInstanceBankAccounts } from "./hooks/bank.js"; +import { useInstanceKYCDetails } from "./hooks/instance.js"; +import { usePreference } from "./hooks/preference.js"; +import { useSessionState } from "./hooks/session.js"; +import InstanceCreatePage from "./paths/admin/create/index.js"; +import InstanceListPage from "./paths/admin/list/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 ListKYCPage from "./paths/instance/kyc/list/index.js"; +import OrderCreatePage from "./paths/instance/orders/create/index.js"; +import OrderDetailsPage from "./paths/instance/orders/details/index.js"; +import OrderListPage from "./paths/instance/orders/list/index.js"; +import ValidatorCreatePage from "./paths/instance/otp_devices/create/index.js"; +import ValidatorListPage from "./paths/instance/otp_devices/list/index.js"; +import ValidatorUpdatePage from "./paths/instance/otp_devices/update/index.js"; +import 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 TemplateCreatePage from "./paths/instance/templates/create/index.js"; +import TemplateListPage from "./paths/instance/templates/list/index.js"; +import TemplateQrPage from "./paths/instance/templates/qr/index.js"; +import TemplateUpdatePage from "./paths/instance/templates/update/index.js"; +import TemplateUsePage from "./paths/instance/templates/use/index.js"; +import TokenPage from "./paths/instance/token/index.js"; +import TransferCreatePage from "./paths/instance/transfers/create/index.js"; +import TransferListPage from "./paths/instance/transfers/list/index.js"; +import InstanceUpdatePage, { + AdminUpdate as InstanceAdminUpdatePage, + Props as InstanceUpdatePageProps, +} from "./paths/instance/update/index.js"; +import 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 { LoginPage } from "./paths/login/index.js"; +import NotFoundPage from "./paths/notfound/index.js"; +import { Settings } from "./paths/settings/index.js"; +import { Notification } from "./utils/types.js"; + +export enum InstancePaths { + error = "/error", + settings = "/settings", + token = "/token", + + bank_list = "/bank", + bank_update = "/bank/:bid/update", + bank_new = "/bank/new", + + inventory_list = "/inventory", + inventory_update = "/inventory/:pid/update", + inventory_new = "/inventory/new", + + order_list = "/orders", + order_new = "/order/new", + order_details = "/order/:oid/details", + + reserves_list = "/reserves", + reserves_details = "/reserves/:rid/details", + reserves_new = "/reserves/new", + + kyc = "/kyc", + + transfers_list = "/transfers", + transfers_new = "/transfer/new", + + templates_list = "/templates", + templates_update = "/templates/:tid/update", + templates_new = "/templates/new", + templates_use = "/templates/:tid/use", + templates_qr = "/templates/:tid/qr", + + webhooks_list = "/webhooks", + webhooks_update = "/webhooks/:tid/update", + webhooks_new = "/webhooks/new", + + otp_devices_list = "/otp-devices", + otp_devices_update = "/otp-devices/:vid/update", + otp_devices_new = "/otp-devices/new", + + interface = "/interface", +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + +export enum AdminPaths { + list_instances = "/instances", + new_instance = "/instance/new", + update_instance = "/instance/:id/update", +} + +export interface Props {} + +export const privatePages = { + home: urlPattern(/\/home/, () => "#/home"), + go: urlPattern(/\/home/, () => "#/home"), +}; +export const publicPages = { + home: urlPattern(/\/home/, () => "#/home"), + go: urlPattern(/\/home/, () => "#/home"), +}; + +export function Routing(_p: Props): VNode { + const { i18n } = useTranslationContext(); + const { state } = useSessionState(); + + type GlobalNotifState = + | (Notification & { to: string | undefined }) + | undefined; + const [globalNotification, setGlobalNotification] = + useState<GlobalNotifState>(undefined); + + const [error] = useErrorBoundary(); + + const instance = useInstanceBankAccounts(); + const accounts = !instance.ok ? undefined : instance.data.accounts; + const shouldWarnAboutMissingBankAccounts = !state.isAdmin && accounts !== undefined && accounts.length < 1 + const shouldLogin = state.status === "loggedOut" || state.status === "expired"; + + function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) { + return function ServerErrorRedirectToImpl( + error: HttpError<MerchantBackend.ErrorDetail>, + ) { + if (error.type === ErrorType.TIMEOUT) { + setGlobalNotification({ + message: i18n.str`The request to the backend take too long and was cancelled`, + description: i18n.str`Diagnostic from ${error.info.url} is "${error.message}"`, + type: "ERROR", + to, + }); + } else { + setGlobalNotification({ + message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, + description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, + details: + error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER + ? error.payload.detail + : undefined, + type: "ERROR", + to, + }); + } + return <Redirect to={to} />; + }; + } + + // const LoginPageAccessDeniend = onUnauthorized + const LoginPageAccessDenied = () => { + return ( + <Fragment> + <NotificationCard + notification={{ + message: i18n.str`Access denied`, + description: i18n.str`Session expired or password changed.`, + type: "ERROR", + }} + /> + <LoginPage /> + </Fragment> + ); + }; + + function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<unknown>) { + return function IfAdminCreateDefaultOrImpl(props?: T) { + if (state.isAdmin && state.instance === "default") { + return ( + <Fragment> + <NotificationCard + notification={{ + message: i18n.str`No 'default' instance configured yet.`, + description: i18n.str`Create a 'default' instance to begin using the merchant backoffice.`, + type: "INFO", + }} + /> + <InstanceCreatePage + forceId="default" + onConfirm={() => { + route(InstancePaths.bank_list); + }} + /> + </Fragment> + ); + } + if (props) { + return <Next {...props} />; + } + return <Next />; + }; + } + + if (shouldLogin) { + return ( + <Fragment> + <NotConnectedAppMenu title="Welcome!" /> + <LoginPage /> + </Fragment> + ); + } + + if (shouldWarnAboutMissingBankAccounts) { + return ( + <Fragment> + <Menu /> + <NotificationCard + notification={{ + type: "INFO", + message: i18n.str`You need to associate a bank account to receive revenue.`, + description: i18n.str`Without this the merchant backend will refuse to create new orders.`, + }} + /> + <BankAccountCreatePage onConfirm={() => {}} /> + </Fragment> + ); + } + + return ( + <Fragment> + <Menu /> + <KycBanner /> + <NotificationCard notification={globalNotification} /> + {error && ( + <NotificationCard + notification={{ + message: "Internal error, please repot", + type: "ERROR", + description: ( + <pre> + { + (error instanceof Error + ? error.stack + : String(error)) as TranslatedString + } + </pre> + ), + }} + /> + )} + + <Router + onChange={(e) => { + const movingOutFromNotification = + globalNotification && e.url !== globalNotification.to; + if (movingOutFromNotification) { + setGlobalNotification(undefined); + } + }} + > + <Route path="/" component={Redirect} to={InstancePaths.order_list} /> + {/** + * Admin pages + */} + {state.isAdmin && ( + <Route + path={AdminPaths.list_instances} + component={InstanceListPage} + onCreate={() => { + route(AdminPaths.new_instance); + }} + onUpdate={(id: string): void => { + route(`/instance/${id}/update`); + }} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.error)} + /> + )} + {state.isAdmin && ( + <Route + path={AdminPaths.new_instance} + component={InstanceCreatePage} + onBack={() => route(AdminPaths.list_instances)} + onConfirm={() => { + route(InstancePaths.order_list); + }} + /> + )} + {state.isAdmin && ( + <Route + path={AdminPaths.update_instance} + component={AdminInstanceUpdatePage} + onBack={() => route(AdminPaths.list_instances)} + onConfirm={() => { + route(AdminPaths.list_instances); + }} + onUpdateError={ServerErrorRedirectTo(AdminPaths.list_instances)} + onLoadError={ServerErrorRedirectTo(AdminPaths.list_instances)} + onNotFound={NotFoundPage} + /> + )} + {/** + * Update instance page + */} + <Route + path={InstancePaths.settings} + component={InstanceUpdatePage} + onBack={() => { + route(`/`); + }} + onConfirm={() => { + route(`/`); + }} + onUpdateError={noop} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.error)} + /> + {/** + * Update instance page + */} + <Route + path={InstancePaths.token} + component={TokenPage} + onChange={() => { + route(`/`); + }} + onCancel={() => { + route(InstancePaths.order_list); + }} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.error)} + /> + {/** + * Inventory pages + */} + <Route + path={InstancePaths.inventory_list} + component={ProductListPage} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} + onCreate={() => { + route(InstancePaths.inventory_new); + }} + onSelect={(id: string) => { + route(InstancePaths.inventory_update.replace(":pid", id)); + }} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + /> + <Route + path={InstancePaths.inventory_update} + component={ProductUpdatePage} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.inventory_list)} + onConfirm={() => { + route(InstancePaths.inventory_list); + }} + onBack={() => { + route(InstancePaths.inventory_list); + }} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + /> + <Route + path={InstancePaths.inventory_new} + component={ProductCreatePage} + onConfirm={() => { + route(InstancePaths.inventory_list); + }} + onBack={() => { + route(InstancePaths.inventory_list); + }} + /> + {/** + * Bank pages + */} + <Route + path={InstancePaths.bank_list} + component={BankAccountListPage} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} + 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.inventory_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 + path={InstancePaths.order_list} + component={OrderListPage} + onCreate={() => { + route(InstancePaths.order_new); + }} + onSelect={(id: string) => { + route(InstancePaths.order_details.replace(":oid", id)); + }} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + /> + <Route + path={InstancePaths.order_details} + component={OrderDetailsPage} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.order_list)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onBack={() => { + route(InstancePaths.order_list); + }} + /> + <Route + path={InstancePaths.order_new} + component={OrderCreatePage} + onConfirm={(orderId: string) => { + route(InstancePaths.order_details.replace(":oid", orderId)); + }} + onBack={() => { + route(InstancePaths.order_list); + }} + /> + {/** + * Transfer pages + */} + <Route + path={InstancePaths.transfers_list} + component={TransferListPage} + onUnauthorized={LoginPageAccessDenied} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} + onCreate={() => { + route(InstancePaths.transfers_new); + }} + /> + <Route + path={InstancePaths.transfers_new} + component={TransferCreatePage} + onConfirm={() => { + route(InstancePaths.transfers_list); + }} + onBack={() => { + route(InstancePaths.transfers_list); + }} + /> + {/** + * Webhooks pages + */} + <Route + path={InstancePaths.webhooks_list} + component={WebhookListPage} + onUnauthorized={LoginPageAccessDenied} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} + onCreate={() => { + route(InstancePaths.webhooks_new); + }} + onSelect={(id: string) => { + route(InstancePaths.webhooks_update.replace(":tid", id)); + }} + /> + <Route + path={InstancePaths.webhooks_update} + component={WebhookUpdatePage} + onConfirm={() => { + route(InstancePaths.webhooks_list); + }} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.webhooks_list)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onBack={() => { + route(InstancePaths.webhooks_list); + }} + /> + <Route + path={InstancePaths.webhooks_new} + component={WebhookCreatePage} + onConfirm={() => { + route(InstancePaths.webhooks_list); + }} + onBack={() => { + route(InstancePaths.webhooks_list); + }} + /> + {/** + * Validator pages + */} + <Route + path={InstancePaths.otp_devices_list} + component={ValidatorListPage} + onUnauthorized={LoginPageAccessDenied} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} + onCreate={() => { + route(InstancePaths.otp_devices_new); + }} + onSelect={(id: string) => { + route(InstancePaths.otp_devices_update.replace(":vid", id)); + }} + /> + <Route + path={InstancePaths.otp_devices_update} + component={ValidatorUpdatePage} + onConfirm={() => { + route(InstancePaths.otp_devices_list); + }} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.otp_devices_list)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onBack={() => { + route(InstancePaths.otp_devices_list); + }} + /> + <Route + path={InstancePaths.otp_devices_new} + component={ValidatorCreatePage} + onConfirm={() => { + route(InstancePaths.otp_devices_list); + }} + onBack={() => { + route(InstancePaths.otp_devices_list); + }} + /> + {/** + * Templates pages + */} + <Route + path={InstancePaths.templates_list} + component={TemplateListPage} + onUnauthorized={LoginPageAccessDenied} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onLoadError={ServerErrorRedirectTo(InstancePaths.settings)} + onCreate={() => { + route(InstancePaths.templates_new); + }} + onNewOrder={(id: string) => { + route(InstancePaths.templates_use.replace(":tid", id)); + }} + onQR={(id: string) => { + route(InstancePaths.templates_qr.replace(":tid", id)); + }} + onSelect={(id: string) => { + route(InstancePaths.templates_update.replace(":tid", id)); + }} + /> + <Route + path={InstancePaths.templates_update} + component={TemplateUpdatePage} + onConfirm={() => { + route(InstancePaths.templates_list); + }} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.templates_list)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onBack={() => { + route(InstancePaths.templates_list); + }} + /> + <Route + path={InstancePaths.templates_new} + component={TemplateCreatePage} + onConfirm={() => { + route(InstancePaths.templates_list); + }} + onBack={() => { + route(InstancePaths.templates_list); + }} + /> + <Route + path={InstancePaths.templates_use} + component={TemplateUsePage} + onOrderCreated={(id: string) => { + route(InstancePaths.order_details.replace(":oid", id)); + }} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.templates_list)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onBack={() => { + route(InstancePaths.templates_list); + }} + /> + <Route + path={InstancePaths.templates_qr} + component={TemplateQrPage} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.templates_list)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onBack={() => { + route(InstancePaths.templates_list); + }} + /> + + <Route path={InstancePaths.kyc} component={ListKYCPage} /> + <Route path={InstancePaths.interface} component={Settings} /> + {/** + * Example pages + */} + <Route path="/loading" component={Loading} /> + <Route default component={NotFoundPage} /> + </Router> + </Fragment> + ); +} + +export function Redirect({ to }: { to: string }): null { + useEffect(() => { + route(to, true); + }); + return null; +} + +function AdminInstanceUpdatePage({ + id, + ...rest +}: { id: string } & InstanceUpdatePageProps): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <InstanceAdminUpdatePage + {...rest} + instanceId={id} + onLoadError={(error: HttpError<MerchantBackend.ErrorDetail>) => { + const notif = + error.type === ErrorType.TIMEOUT + ? { + message: i18n.str`The request to the backend take too long and was cancelled`, + description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, + type: "ERROR" as const, + } + : { + message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, + description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, + details: + error.type === ErrorType.CLIENT || + error.type === ErrorType.SERVER + ? error.payload.detail + : undefined, + type: "ERROR" as const, + }; + return ( + <Fragment> + <NotificationCard notification={notif} /> + <LoginPage /> + </Fragment> + ); + }} + onUnauthorized={() => { + return ( + <Fragment> + <NotificationCard + notification={{ + message: i18n.str`Access denied`, + description: i18n.str`The access token provided is invalid`, + type: "ERROR", + }} + /> + <LoginPage /> + </Fragment> + ); + }} + /> + </Fragment> + ); +} + +function KycBanner(): VNode { + const kycStatus = useInstanceKYCDetails(); + const { i18n } = useTranslationContext(); + // const today = format(new Date(), dateFormatForSettings(settings)); + const [prefs, updatePref] = usePreference(); + + const now = AbsoluteTime.now(); + + const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; + + const hidden = AbsoluteTime.cmp(now, prefs.hideKycUntil) < 1; + if (hidden || !needsToBeShown) return <Fragment />; + + const oneDay = { d_ms: 1000 * 60 * 60 * 24 }; + const tomorrow = AbsoluteTime.addDuration(now, oneDay); + + return ( + <NotificationCard + notification={{ + type: "WARN", + message: "KYC verification needed", + description: ( + <div> + <p> + Some transfer are on hold until a KYC process is completed. Go to + the KYC section in the left panel for more information + </p> + <div class="buttons is-right"> + <button + class="button" + onClick={() => updatePref("hideKycUntil", tomorrow)} + > + <i18n.Translate>Hide for today</i18n.Translate> + </button> + </div> + </div> + ), + }} + /> + ); +} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -21,38 +21,29 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { useBackendContext } from "../../context/backend.js"; import { useConfigContext } from "../../context/config.js"; import { useInstanceKYCDetails } from "../../hooks/instance.js"; import { LangSelector } from "./LangSelector.js"; +import { useSessionState } from "../../hooks/session.js"; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; interface Props { - onLogout: () => void; - onShowSettings: () => void; mobile?: boolean; - instance: string; - admin?: boolean; mimic?: boolean; - isPasswordOk: boolean; } export function Sidebar({ mobile, - instance, - onShowSettings, - onLogout, - admin, mimic, - isPasswordOk }: Props): VNode { const config = useConfigContext(); - const { url: backendURL } = useBackendContext() + // const { url: backendURL } = useBackendContext() const { i18n } = useTranslationContext(); const kycStatus = useInstanceKYCDetails(); const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; + const { state } = useSessionState(); return ( <aside class="aside is-placed-left is-expanded" style={{ overflowY: "scroll" }}> @@ -197,7 +188,9 @@ export function Sidebar({ <ul class="menu-list"> <li> <a class="has-icon is-state-info is-hoverable" - onClick={(): void => onShowSettings()} + onClick={(e): void => { + e.preventDefault() + }} > <span class="icon"> <i class="mdi mdi-newspaper" /> diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx @@ -17,10 +17,12 @@ import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { AdminPaths } from "../../AdminRoutes.js"; -import { InstancePaths } from "../../Rounting.js"; +import { InstancePaths } from "../../Routing.js"; import { Notification } from "../../utils/types.js"; import { NavigationBar } from "./NavigationBar.js"; import { Sidebar } from "./SideBar.js"; +import { useSessionState } from "../../hooks/session.js"; +import { useNavigationContext } from "@gnu-taler/web-util/browser"; function getInstanceTitle(path: string, id: string): string { switch (path) { @@ -77,13 +79,7 @@ function getAdminTitle(path: string, instance: string) { return getInstanceTitle(path, instance); } -interface MenuProps { - title?: string; - instance: string; - admin?: boolean; - onLogout?: () => void; - onShowSettings: () => void; -} +interface MenuProps {} function WithTitle({ title, @@ -98,25 +94,19 @@ function WithTitle({ return <Fragment>{children}</Fragment>; } -export function Menu({ - onLogout, - onShowSettings, - title, - instance, - path, - admin, - setInstanceName, - isPasswordOk -}: MenuProps): VNode { +export function Menu(_p: MenuProps): VNode { const [mobileOpen, setMobileOpen] = useState(false); - const titleWithSubtitle = title - ? title - : !admin - ? getInstanceTitle(path, instance) - : getAdminTitle(path, instance); - const adminInstance = instance === "default"; - const mimic = admin && !adminInstance; + const { state, logIn } = useSessionState(); + const { path } = useNavigationContext(); + + const titleWithSubtitle = !state.isAdmin + ? getInstanceTitle(path, state.instance) + : getAdminTitle(path, state.instance); + + const isLoggedIn = + state.status === "loggedIn" || state.status === "impersonate"; + return ( <WithTitle title={titleWithSubtitle}> <div @@ -128,32 +118,32 @@ export function Menu({ title={titleWithSubtitle} /> - {onLogout && ( - <Sidebar - onShowSettings={onShowSettings} - onLogout={onLogout} - admin={admin} - mimic={mimic} - instance={instance} - mobile={mobileOpen} - isPasswordOk={isPasswordOk} - /> + {isLoggedIn && ( + <Sidebar mobile={mobileOpen} mimic={state.status === "impersonate"} /> )} - {mimic && ( - <nav class="level" style={{ - zIndex: 100, - position: "fixed", - width: "50%", - marginLeft: "20%" - }}> + {state.status === "impersonate" && ( + <nav + class="level" + style={{ + zIndex: 100, + position: "fixed", + width: "50%", + marginLeft: "20%", + }} + > <div class="level-item has-text-centered has-background-warning"> <p class="is-size-5"> - You are viewing the instance <b>"{instance}"</b>.{" "} + You are viewing the instance <b>"{state.instance}"</b> + .{" "} <a href="#/instances" onClick={(e) => { - setInstanceName("default"); + logIn({ + instance: state.originalInstance, + token: state.originalToken, + }); + e.preventDefault(); }} > go back @@ -235,17 +225,16 @@ export function NotConnectedAppMenu({ ); } -export function NotYetReadyAppMenu({ - onLogout, - onShowSettings, - title, - isPasswordOk -}: NotYetReadyAppMenuProps): VNode { +export function NotYetReadyAppMenu({ title }: NotYetReadyAppMenuProps): VNode { const [mobileOpen, setMobileOpen] = useState(false); + const { state } = useSessionState(); useEffect(() => { document.title = `Taler Backoffice: ${title}`; }, [title]); + + const isLoggedIn = + state.status === "loggedIn" || state.status === "impersonate"; return ( <div @@ -256,9 +245,7 @@ export function NotYetReadyAppMenu({ onMobileMenu={() => setMobileOpen(!mobileOpen)} title={title} /> - {onLogout && ( - <Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} isPasswordOk={isPasswordOk} /> - )} + {isLoggedIn && <Sidebar mobile={mobileOpen} />} </div> ); } diff --git a/packages/merchant-backoffice-ui/src/hooks/session.ts b/packages/merchant-backoffice-ui/src/hooks/session.ts @@ -111,8 +111,8 @@ export interface SessionStateHandler { state: SessionState; logOut(): void; expired(): void; - logIn(info: { token: AccessToken }): void; - impersonate(info: { instance: string; token: AccessToken }): void; + logIn(info: { instance: string; token?: AccessToken }): void; + impersonate(info: { instance: string; token?: AccessToken }): void; } const SESSION_STATE_KEY = buildStorageKey("merchant-session", codecForSessionState()); @@ -170,7 +170,7 @@ export function useSessionState(): SessionStateHandler { // admin is defined by the username const nextState: SessionState = { status: "loggedIn", - instance: state.instance, + instance: info.instance, token: info.token, isAdmin: state.instance === DEFAULT_ADMIN_USERNAME, }; diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx @@ -23,6 +23,7 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { StateUpdater, useEffect, useState } from "preact/hooks"; import { MerchantBackend } from "../../../declaration.js"; +import { useSessionState } from "../../../hooks/session.js"; interface Props { instances: MerchantBackend.Instances.Instance[]; @@ -31,7 +32,6 @@ interface Props { onPurge: (id: MerchantBackend.Instances.Instance) => void; onCreate: () => void; selected?: boolean; - setInstanceName: (s: string) => void; } export function CardTable({ @@ -39,7 +39,6 @@ export function CardTable({ onCreate, onUpdate, onPurge, - setInstanceName, onDelete, selected, }: Props): VNode { @@ -114,7 +113,6 @@ export function CardTable({ instances={instances} onPurge={onPurge} onUpdate={onUpdate} - setInstanceName={setInstanceName} onDelete={onDelete} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} @@ -135,7 +133,6 @@ interface TableProps { onDelete: (id: MerchantBackend.Instances.Instance) => void; onPurge: (id: MerchantBackend.Instances.Instance) => void; rowSelectionHandler: StateUpdater<string[]>; - setInstanceName: (s: string) => void; } function toggleSelected<T>(id: T): (prev: T[]) => T[] { @@ -146,13 +143,13 @@ function toggleSelected<T>(id: T): (prev: T[]) => T[] { function Table({ rowSelection, rowSelectionHandler, - setInstanceName, instances, onUpdate, onDelete, onPurge, }: TableProps): VNode { const { i18n } = useTranslationContext(); + const { impersonate } = useSessionState() return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> @@ -203,7 +200,8 @@ function Table({ <a href={`#/orders?instance=${i.id}`} onClick={(e) => { - setInstanceName(i.id); + impersonate({instance: i.id}); + e.preventDefault(); }} > {i.id} diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx @@ -32,7 +32,6 @@ interface Props { onDelete: (id: MerchantBackend.Instances.Instance) => void; onPurge: (id: MerchantBackend.Instances.Instance) => void; selected?: boolean; - setInstanceName: (s: string) => void; } export function View({ @@ -41,7 +40,6 @@ export function View({ onDelete, onPurge, onUpdate, - setInstanceName, selected, }: Props): VNode { const [show, setShow] = useState<"active" | "deleted" | null>("active"); @@ -100,7 +98,6 @@ export function View({ instances={showingInstances} onDelete={onDelete} onPurge={onPurge} - setInstanceName={setInstanceName} onUpdate={onUpdate} selected={selected} onCreate={onCreate} diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx @@ -42,7 +42,6 @@ interface Props { onUnauthorized: () => VNode; onNotFound: () => VNode; onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; - setInstanceName: (s: string) => void; } export default function Instances({ @@ -51,7 +50,6 @@ export default function Instances({ onNotFound, onCreate, onUpdate, - setInstanceName, }: Props): VNode { const result = useBackendInstances(); const [deleting, setDeleting] = @@ -86,7 +84,6 @@ export default function Instances({ onCreate={onCreate} onPurge={setPurging} onUpdate={onUpdate} - setInstanceName={setInstanceName} selected={!!deleting} /> {deleting && ( diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts @@ -1431,6 +1431,7 @@ export const codecForMerchantContractTerms = (): Codec<MerchantContractTerms> => .property("exchanges", codecForList(codecForExchangeHandle())) .property("products", codecOptional(codecForList(codecForProduct()))) .property("extra", codecForAny()) + .property("minimum_age", codecOptional(codecForNumber())) .build("MerchantContractTerms"); export const codecForPeerContractTerms = (): Codec<PeerContractTerms> =>