wallet-core/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx

641 lines
20 KiB
TypeScript
Raw Normal View History

/*
This file is part of GNU Taler
2022-12-16 20:59:37 +01:00
(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)
*/
2023-02-08 21:39:39 +01:00
import {
useTranslationContext,
HttpError,
} from "@gnu-taler/web-util/lib/index.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";
2022-11-04 14:24:29 +01:00
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,
2022-11-04 14:24:29 +01:00
} from "./hooks/index.js";
import { useInstanceKYCDetails } from "./hooks/instance.js";
2022-11-04 14:24:29 +01:00
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";
2022-11-04 14:24:29 +01:00
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";
2023-01-27 16:35:10 +01:00
import TemplateUsePage from "./paths/instance/templates/use/index.js";
import TemplateListPage from "./paths/instance/templates/list/index.js";
import TemplateUpdatePage from "./paths/instance/templates/update/index.js";
2023-01-27 19:08:03 +01:00
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,
2022-11-04 14:24:29 +01:00
} 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";
2023-02-08 21:39:39 +01:00
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",
2022-12-19 20:25:09 +01:00
templates_list = "/templates",
templates_update = "/templates/:tid/update",
templates_new = "/templates/new",
2023-01-27 16:35:10 +01:00
templates_use = "/templates/:tid/use",
2023-01-27 19:08:03 +01:00
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 }),
2022-12-19 16:23:39 +01:00
[id, token, admin],
);
function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) {
2023-02-08 21:39:39 +01:00
return function ServerErrorRedirectToImpl(
error: HttpError<MerchantBackend.ErrorDetail>,
) {
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.clientError || error.serverError
? error.error?.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);
}}
/>
2023-01-27 19:08:03 +01:00
{/**
* 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);
}}
/>
2022-12-19 20:25:09 +01:00
{/**
* Templates pages
*/}
<Route
path={InstancePaths.templates_list}
component={TemplateListPage}
onUnauthorized={LoginPageAccessDenied}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onLoadError={ServerErrorRedirectTo(InstancePaths.update)}
onCreate={() => {
route(InstancePaths.templates_new);
}}
2023-01-27 16:35:10 +01:00
onNewOrder={(id: string) => {
route(InstancePaths.templates_use.replace(":tid", id));
}}
2022-12-19 20:25:09 +01:00
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);
}}
/>
2023-01-27 16:35:10 +01:00
<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);
}}
/>
{/**
* 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();
2023-02-08 21:39:39 +01:00
const updateLoginStatus = (url: string, token?: string): void => {
changeBackend(url);
if (token) changeToken(token);
};
const value = useMemo(
() => ({ id, token, admin: true, changeToken }),
2022-12-19 16:23:39 +01:00
[id, token],
);
const { i18n } = useTranslationContext();
return (
<InstanceContextProvider value={value}>
<InstanceAdminUpdatePage
{...rest}
instanceId={id}
2023-02-08 21:39:39 +01:00
onLoadError={(error: HttpError<MerchantBackend.ErrorDetail>) => {
return (
<Fragment>
<NotificationCard
notification={{
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.clientError || error.serverError
? error.error?.detail
: undefined,
type: "ERROR",
}}
/>
<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>
),
}}
/>
);
}