diff options
Diffstat (limited to 'packages/merchant-backoffice-ui/src/Rounting.tsx')
-rw-r--r-- | packages/merchant-backoffice-ui/src/Rounting.tsx | 802 |
1 files changed, 802 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/Rounting.tsx b/packages/merchant-backoffice-ui/src/Rounting.tsx new file mode 100644 index 000000000..a10310aa8 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/Rounting.tsx @@ -0,0 +1,802 @@ +/* + 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> + ), + }} + /> + ); +} |