wallet-core/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx
2022-12-16 16:59:37 -03:00

529 lines
16 KiB
TypeScript

/*
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 { 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 { HttpError } from "./hooks/backend.js";
import { Translate, useTranslator } from "./i18n/index.js";
import InstanceCreatePage from "./paths/admin/create/index.js";
import InstanceListPage from "./paths/admin/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 TransferListPage from "./paths/instance/transfers/list/index.js";
import TransferCreatePage from "./paths/instance/transfers/create/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 ListKYCPage from "./paths/instance/kyc/list/index.js";
import InstanceUpdatePage, {
Props as InstanceUpdatePageProps,
AdminUpdate as InstanceAdminUpdatePage,
} 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 { useInstanceKYCDetails } from "./hooks/instance.js";
import { format } from "date-fns";
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",
}
// 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;
setInstanceName: (s: string) => void;
}
export function InstanceRoutes({ id, admin, 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 = useTranslator();
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) {
setGlobalNotification({
message: i18n`The backend reported a problem: HTTP status #${error.status}`,
description: i18n`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`Access denied`,
description: i18n`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`No 'default' instance configured yet.`,
description: i18n`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}
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);
}}
/>
{/**
* 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) {
const [token, changeToken] = useBackendInstanceToken(id);
const { updateLoginStatus: changeBackend } = useBackendContext();
const updateLoginStatus = (url: string, token?: string) => {
changeBackend(url);
if (token) changeToken(token);
};
const value = useMemo(
() => ({ id, token, admin: true, changeToken }),
[id, token]
);
const i18n = useTranslator();
return (
<InstanceContextProvider value={value}>
<InstanceAdminUpdatePage
{...rest}
instanceId={id}
onLoadError={(error: HttpError) => {
return (
<Fragment>
<NotificationCard
notification={{
message: i18n`The backend reported a problem: HTTP status #${error.status}`,
description: i18n`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`Access denied`,
description: i18n`The access token provided is invalid`,
type: "ERROR",
}}
/>
<LoginPage onConfirm={updateLoginStatus} />
</Fragment>
);
}}
/>
</InstanceContextProvider>
);
}
function KycBanner(): VNode {
const kycStatus = useInstanceKYCDetails();
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)}>
<Translate>Hide for today</Translate>
</button>
</div>
</div>
),
}}
/>
);
}