a2668c22f0
removed axios, use fetch removed jest, added mocha and chai moved the default request handler to runtime dependency (so it can be replaced for testing) refactored ALL the test to the standard web-utils all hooks now use ONE request handler moved the tests from test folder to src
572 lines
18 KiB
TypeScript
572 lines
18 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 { useTranslationContext } 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";
|
|
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 { HttpError } from "./utils/request.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 TemplateListPage from "./paths/instance/templates/list/index.js";
|
|
import TemplateUpdatePage from "./paths/instance/templates/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";
|
|
|
|
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",
|
|
}
|
|
|
|
// 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) {
|
|
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);
|
|
}}
|
|
/>
|
|
{/**
|
|
* Templates pages
|
|
*/}
|
|
<Route
|
|
path={InstancePaths.templates_list}
|
|
component={TemplateListPage}
|
|
onUnauthorized={LoginPageAccessDenied}
|
|
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
|
|
onLoadError={ServerErrorRedirectTo(InstancePaths.update)}
|
|
onCreate={() => {
|
|
route(InstancePaths.templates_new);
|
|
}}
|
|
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);
|
|
}}
|
|
/>
|
|
|
|
{/**
|
|
* 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) => {
|
|
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) => {
|
|
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>
|
|
),
|
|
}}
|
|
/>
|
|
);
|
|
}
|