/* 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, HttpError, ErrorType, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, FunctionComponent, h, VNode } from "preact"; import { Route, route, Router } from "preact-router"; import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; import { Loading } from "./components/exception/loading.js"; import { Menu, NotificationCard } from "./components/menu/index.js"; import { useBackendContext } from "./context/backend.js"; import { InstanceContextProvider } from "./context/instance.js"; import { useBackendDefaultToken, useBackendInstanceToken, useLocalStorage, } from "./hooks/index.js"; import { useInstanceKYCDetails } from "./hooks/instance.js"; import InstanceCreatePage from "./paths/admin/create/index.js"; import InstanceListPage from "./paths/admin/list/index.js"; import 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 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 ReservesCreatePage from "./paths/instance/reserves/create/index.js"; import ReservesDetailsPage from "./paths/instance/reserves/details/index.js"; import ReservesListPage from "./paths/instance/reserves/list/index.js"; import TemplateCreatePage from "./paths/instance/templates/create/index.js"; import TemplateUsePage from "./paths/instance/templates/use/index.js"; import TemplateQrPage from "./paths/instance/templates/qr/index.js"; import TemplateListPage from "./paths/instance/templates/list/index.js"; import TemplateUpdatePage from "./paths/instance/templates/update/index.js"; import WebhookCreatePage from "./paths/instance/webhooks/create/index.js"; import WebhookListPage from "./paths/instance/webhooks/list/index.js"; import WebhookUpdatePage from "./paths/instance/webhooks/update/index.js"; import TransferCreatePage from "./paths/instance/transfers/create/index.js"; import TransferListPage from "./paths/instance/transfers/list/index.js"; import InstanceUpdatePage, { AdminUpdate as InstanceAdminUpdatePage, Props as InstanceUpdatePageProps, } from "./paths/instance/update/index.js"; import LoginPage from "./paths/login/index.js"; import NotFoundPage from "./paths/notfound/index.js"; import { Notification } from "./utils/types.js"; import { MerchantBackend } from "./declaration.js"; export enum InstancePaths { // details = '/', error = "/error", update = "/update", product_list = "/products", product_update = "/product/:pid/update", product_new = "/product/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", } // 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 { id: string; admin?: boolean; path: string; setInstanceName: (s: string) => void; } export function InstanceRoutes({ id, admin, path, setInstanceName, }: Props): VNode { const [_, 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); } }; const updateLoginStatus = (url: string, token?: string) => { changeBackend(url); if (!token) return; changeToken(token); }; const value = useMemo( () => ({ id, token, admin, changeToken }), [id, token, admin], ); function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) { return function ServerErrorRedirectToImpl( error: HttpError<MerchantBackend.ErrorDetail>, ) { if (error.type === ErrorType.TIMEOUT) { setGlobalNotification({ message: i18n.str`The request to the backend take too long and was cancelled`, description: i18n.str`Diagnostic from ${error.info.url} is "${error.message}"`, type: "ERROR", to, }); } else { setGlobalNotification({ message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, details: error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER ? error.payload.detail : undefined, type: "ERROR", to, }); } return <Redirect to={to} />; }; } const LoginPageAccessDenied = () => ( <Fragment> <NotificationCard notification={{ message: i18n.str`Access denied`, description: i18n.str`The access token provided is invalid.`, type: "ERROR", }} /> <LoginPage onConfirm={updateLoginStatus} /> </Fragment> ); function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) { return function IfAdminCreateDefaultOrImpl(props?: T) { if (admin && id === "default") { return ( <Fragment> <NotificationCard notification={{ message: i18n.str`No 'default' instance configured yet.`, description: i18n.str`Create a 'default' instance to begin using the merchant backoffice.`, type: "INFO", }} /> <InstanceCreatePage forceId="default" onConfirm={() => { route(AdminPaths.list_instances); }} /> </Fragment> ); } if (props) { return <Next {...props} />; } return <Next />; }; } const clearTokenAndGoToRoot = () => { clearAllTokens(); route("/"); }; return ( <InstanceContextProvider value={value}> <Menu instance={id} admin={admin} path={path} onLogout={clearTokenAndGoToRoot} setInstanceName={setInstanceName} /> <KycBanner /> <NotificationCard notification={globalNotification} /> <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(AdminPaths.list_instances); }} /> )} {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.update} component={InstanceUpdatePage} onBack={() => { route(`/`); }} onConfirm={() => { route(`/`); }} onUpdateError={noop} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onUnauthorized={LoginPageAccessDenied} onLoadError={ServerErrorRedirectTo(InstancePaths.error)} /> {/** * Product pages */} <Route path={InstancePaths.product_list} component={ProductListPage} onUnauthorized={LoginPageAccessDenied} onLoadError={ServerErrorRedirectTo(InstancePaths.update)} onCreate={() => { route(InstancePaths.product_new); }} onSelect={(id: string) => { route(InstancePaths.product_update.replace(":pid", id)); }} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} /> <Route path={InstancePaths.product_update} component={ProductUpdatePage} onUnauthorized={LoginPageAccessDenied} onLoadError={ServerErrorRedirectTo(InstancePaths.product_list)} onConfirm={() => { route(InstancePaths.product_list); }} onBack={() => { route(InstancePaths.product_list); }} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} /> <Route path={InstancePaths.product_new} component={ProductCreatePage} onConfirm={() => { route(InstancePaths.product_list); }} onBack={() => { route(InstancePaths.product_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.update)} 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={() => { route(InstancePaths.order_list); }} onBack={() => { route(InstancePaths.order_list); }} /> {/** * Transfer pages */} <Route path={InstancePaths.transfers_list} component={TransferListPage} onUnauthorized={LoginPageAccessDenied} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onLoadError={ServerErrorRedirectTo(InstancePaths.update)} 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.update)} 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); }} /> {/** * Templates pages */} <Route path={InstancePaths.templates_list} component={TemplateListPage} onUnauthorized={LoginPageAccessDenied} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onLoadError={ServerErrorRedirectTo(InstancePaths.update)} 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); }} /> {/** * reserves pages */} <Route path={InstancePaths.reserves_list} component={ReservesListPage} onUnauthorized={LoginPageAccessDenied} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onLoadError={ServerErrorRedirectTo(InstancePaths.update)} onSelect={(id: string) => { route(InstancePaths.reserves_details.replace(":rid", id)); }} onCreate={() => { route(InstancePaths.reserves_new); }} /> <Route path={InstancePaths.reserves_details} component={ReservesDetailsPage} onUnauthorized={LoginPageAccessDenied} onLoadError={ServerErrorRedirectTo(InstancePaths.reserves_list)} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} onBack={() => { route(InstancePaths.reserves_list); }} /> <Route path={InstancePaths.reserves_new} component={ReservesCreatePage} onConfirm={() => { route(InstancePaths.reserves_list); }} onBack={() => { route(InstancePaths.reserves_list); }} /> <Route path={InstancePaths.kyc} component={ListKYCPage} /> {/** * Example pages */} <Route path="/loading" component={Loading} /> <Route default component={NotFoundPage} /> </Router> </InstanceContextProvider> ); } export function Redirect({ to }: { to: string }): null { useEffect(() => { route(to, true); }); return null; } function AdminInstanceUpdatePage({ id, ...rest }: { id: string } & InstanceUpdatePageProps): VNode { const [token, changeToken] = useBackendInstanceToken(id); const { updateLoginStatus: changeBackend } = useBackendContext(); const updateLoginStatus = (url: string, token?: string): void => { changeBackend(url); if (token) changeToken(token); }; const value = useMemo( () => ({ id, token, admin: true, changeToken }), [id, token], ); const { i18n } = useTranslationContext(); return ( <InstanceContextProvider value={value}> <InstanceAdminUpdatePage {...rest} instanceId={id} onLoadError={(error: HttpError<MerchantBackend.ErrorDetail>) => { const notif = error.type === ErrorType.TIMEOUT ? { message: i18n.str`The request to the backend take too long and was cancelled`, description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, type: "ERROR" as const, } : { message: i18n.str`The backend reported a problem: HTTP status #${error.status}`, description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`, details: error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER ? error.payload.detail : undefined, type: "ERROR" as const, }; return ( <Fragment> <NotificationCard notification={notif} /> <LoginPage onConfirm={updateLoginStatus} /> </Fragment> ); }} onUnauthorized={() => { return ( <Fragment> <NotificationCard notification={{ message: i18n.str`Access denied`, description: i18n.str`The access token provided is invalid`, type: "ERROR", }} /> <LoginPage onConfirm={updateLoginStatus} /> </Fragment> ); }} /> </InstanceContextProvider> ); } function KycBanner(): VNode { const kycStatus = useInstanceKYCDetails(); const { i18n } = useTranslationContext(); const today = format(new Date(), "yyyy-MM-dd"); const [lastHide, setLastHide] = useLocalStorage("kyc-last-hide"); const hasBeenHidden = today === lastHide; const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; if (hasBeenHidden || !needsToBeShown) return <Fragment />; return ( <NotificationCard notification={{ type: "WARN", message: "KYC verification needed", description: ( <div> <p> Some transfer are on hold until a KYC process is completed. Go to the KYC section in the left panel for more information </p> <div class="buttons is-right"> <button class="button" onClick={() => setLastHide(today)}> <i18n.Translate>Hide for today</i18n.Translate> </button> </div> </div> ), }} /> ); }