Routes Frontend + Improvements

This commit is contained in:
Roman 2024-03-28 11:56:17 +01:00
parent 50ae32595d
commit 90e7024a73
10 changed files with 314 additions and 42 deletions

@ -5,6 +5,7 @@ namespace Core\API {
use Core\API\Routes\GenerateCache; use Core\API\Routes\GenerateCache;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
use Core\Objects\Router\ApiRoute;
abstract class RoutesAPI extends Request { abstract class RoutesAPI extends Request {
@ -24,6 +25,8 @@ namespace Core\API {
return false; return false;
} else if ($route === null) { } else if ($route === null) {
return $this->createError("Route not found"); return $this->createError("Route not found");
} else if ($route instanceof ApiRoute) {
return $this->createError("This route cannot be modified");
} }
$route->setActive($active); $route->setActive($active);
@ -71,6 +74,7 @@ namespace Core\API\Routes {
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
use Core\Objects\Router\ApiRoute;
use Core\Objects\Router\Router; use Core\Objects\Router\Router;
class Fetch extends RoutesAPI { class Fetch extends RoutesAPI {
@ -87,10 +91,7 @@ namespace Core\API\Routes {
$this->success = ($routes !== FALSE); $this->success = ($routes !== FALSE);
if ($this->success) { if ($this->success) {
$this->result["routes"] = []; $this->result["routes"] = $routes;
foreach ($routes as $route) {
$this->result["routes"][$route->getId()] = $route->jsonSerialize();
}
} }
return $this->success; return $this->success;
@ -307,7 +308,6 @@ namespace Core\API\Routes {
parent::__construct($context, $externalCall, array( parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT) "id" => new Parameter("id", Parameter::TYPE_INT)
)); ));
$this->isPublic = false;
} }
public function _execute(): bool { public function _execute(): bool {
@ -319,6 +319,8 @@ namespace Core\API\Routes {
return $this->createError("Error fetching route: " . $sql->getLastError()); return $this->createError("Error fetching route: " . $sql->getLastError());
} else if ($route === null) { } else if ($route === null) {
return $this->createError("Route not found"); return $this->createError("Route not found");
} else if ($route instanceof ApiRoute) {
return $this->createError("This route cannot be deleted");
} }
$this->success = $route->delete($sql) !== false; $this->success = $route->delete($sql) !== false;
@ -336,7 +338,6 @@ namespace Core\API\Routes {
parent::__construct($context, $externalCall, array( parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT) "id" => new Parameter("id", Parameter::TYPE_INT)
)); ));
$this->isPublic = false;
} }
public function _execute(): bool { public function _execute(): bool {
@ -354,7 +355,6 @@ namespace Core\API\Routes {
parent::__construct($context, $externalCall, array( parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT) "id" => new Parameter("id", Parameter::TYPE_INT)
)); ));
$this->isPublic = false;
} }
public function _execute(): bool { public function _execute(): bool {
@ -373,7 +373,6 @@ namespace Core\API\Routes {
public function __construct(Context $context, bool $externalCall = false) { public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []); parent::__construct($context, $externalCall, []);
$this->isPublic = false;
$this->router = null; $this->router = null;
} }
@ -408,7 +407,10 @@ namespace Core\API\Routes {
} }
public static function getDefaultACL(Insert $insert): void { public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to regenerate the routing cache", true); $insert->addRow(self::getEndpoint(),
[Group::ADMIN, Group::SUPPORT],
"Allows users to regenerate the routing cache", true
);
} }
} }

@ -5,6 +5,7 @@ namespace Core\Objects\Router;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Elements\Document; use Core\Elements\Document;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
use Core\Objects\Search\Searchable; use Core\Objects\Search\Searchable;
use Core\Objects\Search\SearchQuery; use Core\Objects\Search\SearchQuery;
@ -15,7 +16,10 @@ class DocumentRoute extends Route {
use Searchable; use Searchable;
#[Transient]
private array $args; private array $args;
#[Transient]
private ?\ReflectionClass $reflectionClass = null; private ?\ReflectionClass $reflectionClass = null;
public function __construct(string $pattern, bool $exact, string $className, ...$args) { public function __construct(string $pattern, bool $exact, string $className, ...$args) {

@ -2,11 +2,13 @@
namespace Core\Objects\Router; namespace Core\Objects\Router;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
use JetBrains\PhpStorm\Pure; use JetBrains\PhpStorm\Pure;
class RedirectRoute extends Route { class RedirectRoute extends Route {
#[Transient]
private int $code; private int $code;
public function __construct(string $type, string $pattern, bool $exact, string $destination, int $code = 307) { public function __construct(string $type, string $pattern, bool $exact, string $destination, int $code = 307) {

@ -4,6 +4,7 @@ namespace Core\Objects\Router;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
use Core\Objects\Search\Searchable; use Core\Objects\Search\Searchable;
use Core\Objects\Search\SearchQuery; use Core\Objects\Search\SearchQuery;
@ -14,6 +15,7 @@ class StaticFileRoute extends Route {
use Searchable; use Searchable;
#[Transient]
private int $code; private int $code;
public function __construct(string $pattern, bool $exact, string $path, int $code = 200) { public function __construct(string $pattern, bool $exact, string $path, int $code = 200) {

@ -2,11 +2,15 @@
namespace Core\Objects\Router; namespace Core\Objects\Router;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
class StaticRoute extends Route { class StaticRoute extends Route {
#[Transient]
private string $data; private string $data;
#[Transient]
private int $code; private int $code;
public function __construct(string $pattern, bool $exact, string $data, int $code = 200) { public function __construct(string $pattern, bool $exact, string $data, int $code = 200) {

@ -20,6 +20,7 @@ const GroupListView = lazy(() => import('./views/group/group-list'));
const EditGroupView = lazy(() => import('./views/group/group-edit')); const EditGroupView = lazy(() => import('./views/group/group-edit'));
const LogView = lazy(() => import("./views/log-view")); const LogView = lazy(() => import("./views/log-view"));
const AccessControlList = lazy(() => import("./views/access-control-list")); const AccessControlList = lazy(() => import("./views/access-control-list"));
const RouteListView = lazy(() => import("./views/routes"));
export default function AdminDashboard(props) { export default function AdminDashboard(props) {
@ -79,6 +80,7 @@ export default function AdminDashboard(props) {
<Route path={"/admin/group/:groupId"} element={<EditGroupView {...controlObj} />}/> <Route path={"/admin/group/:groupId"} element={<EditGroupView {...controlObj} />}/>
<Route path={"/admin/logs"} element={<LogView {...controlObj} />}/> <Route path={"/admin/logs"} element={<LogView {...controlObj} />}/>
<Route path={"/admin/acl"} element={<AccessControlList {...controlObj} />}/> <Route path={"/admin/acl"} element={<AccessControlList {...controlObj} />}/>
<Route path={"/admin/routes"} element={<RouteListView {...controlObj} />}/>
<Route path={"*"} element={<View404 />} /> <Route path={"*"} element={<View404 />} />
</Routes> </Routes>
</Suspense> </Suspense>

@ -36,7 +36,7 @@ export default function Sidebar(props) {
"name": "admin.groups", "name": "admin.groups",
"icon": "users-cog" "icon": "users-cog"
}, },
"pages": { "routes": {
"name": "admin.page_routes", "name": "admin.page_routes",
"icon": "copy", "icon": "copy",
}, },

@ -12,12 +12,11 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
IconButton IconButton, styled
} from "@material-ui/core"; } from "@material-ui/core";
import {Add, Delete, Edit, Refresh} from "@material-ui/icons"; import {Add, Delete, Edit, Refresh} from "@material-ui/icons";
import {USER_GROUP_ADMIN} from "shared/constants"; import {USER_GROUP_ADMIN} from "shared/constants";
import Dialog from "shared/elements/dialog"; import Dialog from "shared/elements/dialog";
import {TableFooter} from "@mui/material";
export default function AccessControlList(props) { export default function AccessControlList(props) {
@ -74,18 +73,22 @@ export default function AccessControlList(props) {
const onChangePermission = useCallback((methodIndex, groupId, selected) => { const onChangePermission = useCallback((methodIndex, groupId, selected) => {
let newGroups = null; let newGroups = null;
let currentGroups = acl[methodIndex].groups; let currentGroups = acl[methodIndex].groups;
let groupIndex = currentGroups.indexOf(groupId); if (groupId === null) {
if (!selected) { newGroups = [];
if (currentGroups.length === 0) { } else {
// it was an "everyone permission" before let groupIndex = currentGroups.indexOf(groupId);
newGroups = groups.filter(group => group.id !== groupId).map(group => group.id); if (!selected) {
} else if (groupIndex !== -1 && currentGroups.length > 1) { if (currentGroups.length === 0) {
// it was an "everyone permission" before
newGroups = groups.filter(group => group.id !== groupId).map(group => group.id);
} else if (groupIndex !== -1) {
newGroups = [...currentGroups];
newGroups.splice(groupIndex, 1);
}
} else if (groupIndex === -1) {
newGroups = [...currentGroups]; newGroups = [...currentGroups];
newGroups.splice(groupIndex, 1); newGroups.push(groupId);
} }
} else if (groupIndex === -1) {
newGroups = [...currentGroups];
newGroups.push(groupId);
} }
if (newGroups !== null) { if (newGroups !== null) {
@ -94,6 +97,7 @@ export default function AccessControlList(props) {
let newACL = [...acl]; let newACL = [...acl];
newACL[methodIndex].groups = newGroups; newACL[methodIndex].groups = newGroups;
setACL(newACL); setACL(newACL);
props.api.fetchUser();
} else { } else {
props.showDialog("Error updating permission: " + data.msg); props.showDialog("Error updating permission: " + data.msg);
} }
@ -106,6 +110,7 @@ export default function AccessControlList(props) {
if (data.success) { if (data.success) {
let newACL = acl.filter(acl => acl.method.toLowerCase() !== method.toLowerCase()); let newACL = acl.filter(acl => acl.method.toLowerCase() !== method.toLowerCase());
setACL(newACL); setACL(newACL);
props.api.fetchUser();
} else { } else {
props.showDialog("Error deleting permission: " + data.msg); props.showDialog("Error deleting permission: " + data.msg);
} }
@ -119,6 +124,7 @@ export default function AccessControlList(props) {
newACL.push({method: inputData.method, groups: groups, description: inputData.description}); newACL.push({method: inputData.method, groups: groups, description: inputData.description});
newACL = newACL.sort((a, b) => a.method.localeCompare(b.method)) newACL = newACL.sort((a, b) => a.method.localeCompare(b.method))
setACL(newACL); setACL(newACL);
props.api.fetchUser();
} else { } else {
props.showDialog("Error updating permission: " + data.msg); props.showDialog("Error updating permission: " + data.msg);
} }
@ -180,8 +186,13 @@ export default function AccessControlList(props) {
</div> </div>
</div> </div>
</TableCell> </TableCell>
<BorderedColumn key={"perm-" + index + "-everyone"} align={"center"}>
<Checkbox checked={!permission.groups.length}
onChange={(e) => onChangePermission(index, null, e.target.checked)}
disabled={isRestricted(permission.method)} />
</BorderedColumn>
{groups.map(group => <TableCell key={"perm-" + index + "-group-" + group.id} align={"center"}> {groups.map(group => <TableCell key={"perm-" + index + "-group-" + group.id} align={"center"}>
<Checkbox checked={!permission.groups.length || permission.groups.includes(group.id)} <Checkbox checked={permission.groups.includes(group.id)}
onChange={(e) => onChangePermission(index, group.id, e.target.checked)} onChange={(e) => onChangePermission(index, group.id, e.target.checked)}
disabled={isRestricted(permission.method)} /> disabled={isRestricted(permission.method)} />
</TableCell>)} </TableCell>)}
@ -192,6 +203,11 @@ export default function AccessControlList(props) {
return <>{rows}</> return <>{rows}</>
} }
const BorderedColumn = styled(TableCell)({
borderLeft: "1px dotted #666",
borderRight: "1px dotted #666",
});
return <> return <>
<div className={"content-header"}> <div className={"content-header"}>
<div className={"container-fluid"}> <div className={"container-fluid"}>
@ -244,23 +260,22 @@ export default function AccessControlList(props) {
</div> </div>
</div> </div>
</div> </div>
<div> <TableContainer component={Paper} style={{overflowX: "initial"}}>
<TableContainer component={Paper} style={{overflowX: "initial"}}> <Table stickyHeader size={"small"} className={"table-striped"}>
<Table stickyHeader size={"small"} className={"table-striped"}> <TableHead>
<TableHead> <TableRow>
<TableRow> <TableCell>{L("permission")}</TableCell>
<TableCell>{L("permission")}</TableCell> <BorderedColumn align={"center"}><i>{L("everyone")}</i></BorderedColumn>
{ groups.map(group => <TableCell key={"group-" + group.id} align={"center"}> { groups.map(group => <TableCell key={"group-" + group.id} align={"center"}>
{group.name} {group.name}
</TableCell>) } </TableCell>) }
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
<PermissionList /> <PermissionList />
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
</div>
<Dialog show={dialogData.open} <Dialog show={dialogData.open}
onClose={() => setDialogData({open: false})} onClose={() => setDialogData({open: false})}
title={dialogData.title} title={dialogData.title}
@ -268,6 +283,5 @@ export default function AccessControlList(props) {
onOption={dialogData.onOption} onOption={dialogData.onOption}
inputs={dialogData.inputs} inputs={dialogData.inputs}
options={[L("general.ok"), L("general.cancel")]} /> options={[L("general.ok"), L("general.cancel")]} />
</> </>
} }

@ -0,0 +1,226 @@
import {Link, useNavigate} from "react-router-dom";
import {
Paper,
styled,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton, Button, Checkbox
} from "@material-ui/core";
import {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale";
import {Add, Cached, Delete, Edit, Refresh} from "@material-ui/icons";
import {Quiz} from "@mui/icons-material";
import Dialog from "shared/elements/dialog";
export default function RouteListView(props) {
// meta
const api = props.api;
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);
const navigate = useNavigate();
// data
const [fetchRoutes, setFetchRoutes] = useState(true);
const [routes, setRoutes] = useState({});
// ui
const [dialogData, setDialogData] = useState({show: false});
const [isGeneratingCache, setGeneratingCache] = useState(false);
const onFetchRoutes = useCallback((force = false) => {
if (force || fetchRoutes) {
setFetchRoutes(false);
props.api.fetchRoutes().then(res => {
if (!res.success) {
props.showDialog(res.msg, "Error fetching routes");
navigate("/admin/dashboard");
} else {
setRoutes(res.routes);
}
});
}
}, [fetchRoutes]);
useEffect(() => {
onFetchRoutes();
}, []);
useEffect(() => {
requestModules(props.api, ["general"], currentLocale).then(data => {
if (!data.success) {
props.showDialog("Error fetching translations: " + data.msg);
}
});
}, [currentLocale]);
const onToggleRoute = useCallback((id, active) => {
if (active) {
props.api.enableRoute(id).then(data => {
if (!data.success) {
props.showDialog(data.msg, L("Error enabling route"));
} else {
setRoutes({...routes, [id]: { ...routes[id], active: true }});
}
});
} else {
props.api.disableRoute(id).then(data => {
if (!data.success) {
props.showDialog(data.msg, L("Error enabling route"));
} else {
setRoutes({...routes, [id]: { ...routes[id], active: false }});
}
});
}
}, [routes]);
const onDeleteRoute = useCallback(id => {
props.api.deleteRoute(id).then(data => {
if (!data.success) {
props.showDialog(data.msg, L("Error removing route"));
} else {
let newRoutes = { ...routes };
delete newRoutes[id];
setRoutes(newRoutes);
}
})
}, [routes]);
const onRegenerateCache = useCallback(() => {
if (!isGeneratingCache) {
setGeneratingCache(true);
props.api.regenerateRouterCache().then(data => {
if (!data.success) {
props.showDialog(data.msg, L("Error regenerating router cache"));
setGeneratingCache(false);
} else {
setDialogData({
open: true,
title: L("general.success"),
message: L("Router cache successfully regenerated"),
onClose: () => setGeneratingCache(false)
})
}
});
}
}, [isGeneratingCache]);
const RouteTableRow = styled(TableRow)((props) => ({
"& td": {
fontFamily: "monospace"
}
}));
const BoolCell = (props) => props.checked ? L("general.yes") : L("general.no")
return <>
<div className={"content-header"}>
<div className={"container-fluid"}>
<div className={"row mb-2"}>
<div className={"col-sm-6"}>
<h1 className={"m-0 text-dark"}>Routes</h1>
</div>
<div className={"col-sm-6"}>
<ol className={"breadcrumb float-sm-right"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active">Routes</li>
</ol>
</div>
</div>
</div>
</div>
<div className={"row"}>
<div className={"col-6"} />
<div className={"col-6 text-right"}>
<div className={"form-group"}>
<Button variant={"outlined"} color={"primary"} className={"m-1"} startIcon={<Refresh />} onClick={() => onFetchRoutes(true)}>
{L("general.reload")}
</Button>
<Button variant={"outlined"} className={"m-1"} startIcon={<Add />}
disabled={!props.api.hasPermission("routes/add")}
onClick={() => navigate("/admin/routes/new")} >
{L("general.add")}
</Button>
<Button variant={"outlined"} className={"m-1"} startIcon={<Cached />}
disabled={!props.api.hasPermission("routes/generateCache") || isGeneratingCache}
onClick={onRegenerateCache} >
{isGeneratingCache ? L("regenerating_cache") + "…" : L("regenerate_cache")}
</Button>
</div>
</div>
</div>
<TableContainer component={Paper} style={{overflowX: "initial"}}>
<Table stickyHeader size={"small"} className={"table-striped"}>
<TableHead>
<TableRow>
<TableCell>{L("general.id")}</TableCell>
<TableCell>{L("Route")}</TableCell>
<TableCell>{L("Type")}</TableCell>
<TableCell>{L("Target")}</TableCell>
<TableCell>{L("Extra")}</TableCell>
<TableCell align={"center"}>{L("Active")}</TableCell>
<TableCell align={"center"}>{L("Exact")}</TableCell>
<TableCell align={"center"}>{L("general.controls")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(routes).map(([id, route]) =>
<RouteTableRow key={"route-" + id}>
<TableCell>{route.id}</TableCell>
<TableCell>{route.pattern}</TableCell>
<TableCell>{route.type}</TableCell>
<TableCell>{route.target}</TableCell>
<TableCell>{route.extra}</TableCell>
<TableCell align={"center"}>
<Checkbox checked={route.active}
size={"small"}
disabled={!api.hasPermission(route.active ? "routes/disable" : "routes/enable")}
onChange={(e) => onToggleRoute(route.id, e.target.checked)} />
</TableCell>
<TableCell align={"center"}><BoolCell checked={route.exact} /></TableCell>
<TableCell align={"center"}>
<IconButton size={"small"} title={L("general.edit")}
disabled={!api.hasPermission("routes/add")}
color={"primary"}
onClick={() => navigate("/admin/routes/" + id)}>
<Edit />
</IconButton>
<IconButton size={"small"} title={L("general.delete")}
disabled={!api.hasPermission("routes/remove")}
color={"secondary"}
onClick={() => setDialogData({
open: true,
title: L("Delete Route"),
message: L("Do you really want to delete the following route?"),
inputs: [
{ type: "text", value: route.pattern, disabled: true}
],
options: [L("general.ok"), L("general.cancel")],
onOption: btn => btn === 0 && onDeleteRoute(route.id)
})}>
<Delete />
</IconButton>
<IconButton size={"small"} title={L("general.test")}
disabled={!api.hasPermission("routes/check")}
color={"primary"}>
<Quiz />
</IconButton>
</TableCell>
</RouteTableRow>
)}
</TableBody>
</Table>
</TableContainer>
<Dialog show={dialogData.open}
onClose={() => { setDialogData({open: false}); dialogData.onClose && dialogData.onClose() }}
title={dialogData.title}
message={dialogData.message}
onOption={dialogData.onOption}
inputs={dialogData.inputs}
options={[L("general.ok"), L("general.cancel")]} />
</>
}

@ -213,14 +213,30 @@ export default class API {
} }
/** RoutesAPI **/ /** RoutesAPI **/
async getRoutes() { async fetchRoutes() {
return this.apiCall("routes/fetch"); return this.apiCall("routes/fetch");
} }
async enableRoute(id) {
return this.apiCall("routes/enable", { id: id });
}
async disableRoute(id) {
return this.apiCall("routes/disable", { id: id });
}
async deleteRoute(id) {
return this.apiCall("routes/remove", { id: id });
}
async saveRoutes(routes) { async saveRoutes(routes) {
return this.apiCall("routes/save", { routes: routes }); return this.apiCall("routes/save", { routes: routes });
} }
async regenerateRouterCache() {
return this.apiCall("routes/generateCache");
}
/** GroupAPI **/ /** GroupAPI **/
async createGroup(name, color) { async createGroup(name, color) {
return this.apiCall("groups/create", { name: name, color: color }); return this.apiCall("groups/create", { name: name, color: color });