Removed AdminLTE, some minor improvements

This commit is contained in:
Roman 2024-05-02 14:16:04 +02:00
parent 163ef9fbfe
commit fb353d1bc8
36 changed files with 916 additions and 873 deletions

@ -0,0 +1,36 @@
<?php
namespace Core\API;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
class TestRedis extends Request {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
}
protected function _execute(): bool {
$settings = $this->context->getSettings();
if (!$settings->isRateLimitingEnabled()) {
return $this->createError("Rate Limiting is currently disabled");
}
$connection = $this->context->getRedis();
if ($connection === null || !$connection->isConnected()) {
return $this->createError("Redis connection failed");
}
return $this->success;
}
public static function getDescription(): string {
return "Allows users to test the redis connection with the configured credentials.";
}
public static function getDefaultPermittedGroups(): array {
return [Group::ADMIN];
}
}

@ -55,6 +55,7 @@ return [
"active" => "Aktiv",
"group" => "Gruppe",
"no_members" => "Keine Mitglieder in dieser Gruppe",
"user_list_placeholder" => "Keine Benutzer zum Anzeigen",
# profile picture
"change_picture" => "Profilbild ändern",

@ -83,4 +83,8 @@ return [
"datefns_time_format_precise" => "HH:mm:ss",
"datefns_datetime_format" => "dd.MM.yyyy HH:mm",
"datefns_datetime_format_precise" => "dd.MM.yyyy HH:mm:ss",
# localization
"error_language_fetch" => "Fehler beim Holen der Sprachen",
"error_language_set" => "Fehler beim Setzen der Sprache",
];

@ -55,6 +55,9 @@ return [
"redis_host" => "Redis Host",
"redis_port" => "Redis Port",
"redis_password" => "Redis Passwort",
"redis_test" => "Verbindung testen",
"redis_test_error" => "Redis-Verbindung fehlgeschlagen, überprüfen Sie die Daten.",
"redis_test_success" => "Redis-Verbindung erfolgreich aufgebaut.",
# dialog
"fetch_settings_error" => "Fehler beim Holen der Einstellungen",

@ -57,6 +57,7 @@ return [
"group" => "Group",
"no_members" => "No members in this group",
"edit_profile" => "Edit Profile",
"user_list_placeholder" => "No users to display",
# profile picture
"change_picture" => "Change profile picture",

@ -83,4 +83,8 @@ return [
"datefns_time_format_precise" => "pp",
"datefns_datetime_format" => "MM/dd/yyyy p",
"datefns_datetime_format_precise" => "MM/dd/yyyy pp",
# localization
"error_language_fetch" => "Error fetching languages",
"error_language_set" => "Error setting language",
];

@ -55,6 +55,9 @@ return [
"redis_host" => "Redis host",
"redis_port" => "Redis port",
"redis_password" => "Redis password",
"redis_test" => "Test Connection",
"redis_test_error" => "Redis Connection failed, check your credentials.",
"redis_test_success" => "Redis Connection successfully established.",
# dialog
"fetch_settings_error" => "Error fetching settings",

@ -2,7 +2,6 @@
{% block head %}
<title>{{ site.name }} - {{ L("admin.title") }}</title>
<link rel="stylesheet" href="/css/fontawesome.min.css" nonce="{{ site.csp.nonce }}">
{% endblock %}
{% block body %}

@ -51,6 +51,10 @@ The compiled dist files will be automatically moved to `/js`.
To spawn a temporary development server, run:
```bash
php cli frontend dev <module>
```
or directly via yarn:
```bash
cd react
yarn workspace $project run dev
```
@ -97,7 +101,11 @@ An endpoint consists of two important functions:
To create an API category containing multiple endpoints, a parent class inheriting from `Request`, e.g. `class MultipleAPI extends Request` is required.
All endpoints inside this category then inherit from the `MultipleAPI` class.
The classes must be present inside the [API](/Core/API) directory according to the other endpoints.
The classes must be present inside the [API](/Site/API) directory according to the other endpoints.
You can easily create new classes using the template command:
```bash
php cli.php api add
```
### Access Control
@ -355,7 +363,7 @@ php cli.php api <add> # interactive wizard
│ ├── shared # shared source files, including API and localization
│ ├── admin-panel # the admin panel source files
│ ├── dist # compiler output
├── [js/css/img/fonts/files/docs] # static web assets, files, licenses
├── [js/css/img/fonts/files] # static web assets, files
├── docker # docker configuration files and build scripts
└── test # php unit & integraton test files
```

@ -1,20 +0,0 @@
The MIT License (MIT)
Copyright (c) 2014-2018 almasaeed2010
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -1,8 +1,5 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/css/fontawesome.min.css">
</head>
<body>
<div id="admin-panel"></div>
</body>

@ -6,9 +6,6 @@ import Footer from "./elements/footer";
import {useContext, useEffect} from "react";
import {LocaleContext} from "shared/locale";
// css
import './res/adminlte.min.css';
// views
import View404 from "./views/404";
import clsx from "clsx";
@ -61,37 +58,27 @@ export default function AdminDashboard(props) {
hideDialog: hideDialog
};
// add fixed-layout to body, I don't want to rewrite my base.twig template
if (!document.body.className.includes("layout-fixed")) {
document.body.className = clsx(document.body.className, "layout-fixed");
}
return <BrowserRouter>
<Sidebar {...controlObj} />
<div className={"wrapper"}>
<div className={"content-wrapper p-2"}>
<section className={"content"}>
<Suspense fallback={<div>{L("general.loading")}... </div>}>
<Routes>
<Route path={"/admin"} element={<Navigate to={"/admin/dashboard"} />}/>
<Route path={"/admin/dashboard"} element={<Overview {...controlObj} />}/>
<Route path={"/admin/users"} element={<UserListView {...controlObj} />}/>
<Route path={"/admin/user/:userId"} element={<UserEditView {...controlObj} />}/>
<Route path={"/admin/groups"} element={<GroupListView {...controlObj} />}/>
<Route path={"/admin/group/:groupId"} element={<EditGroupView {...controlObj} />}/>
<Route path={"/admin/logs"} element={<LogView {...controlObj} />}/>
<Route path={"/admin/permissions"} element={<AccessControlList {...controlObj} />}/>
<Route path={"/admin/routes"} element={<RouteListView {...controlObj} />}/>
<Route path={"/admin/routes/:routeId"} element={<RouteEditView {...controlObj} />}/>
<Route path={"/admin/settings"} element={<SettingsView {...controlObj} />}/>
<Route path={"/admin/profile"} element={<ProfileView {...controlObj} />}/>
<Route path={"*"} element={<View404 />} />
</Routes>
</Suspense>
<Dialog {...dialog}/>
</section>
</div>
</div>
<Footer info={info} />
<Sidebar {...controlObj}>
<Suspense fallback={<div>{L("general.loading")}... </div>}>
<Routes>
<Route path={"/admin"} element={<Navigate to={"/admin/dashboard"} />}/>
<Route path={"/admin/dashboard"} element={<Overview {...controlObj} />}/>
<Route path={"/admin/users"} element={<UserListView {...controlObj} />}/>
<Route path={"/admin/user/:userId"} element={<UserEditView {...controlObj} />}/>
<Route path={"/admin/groups"} element={<GroupListView {...controlObj} />}/>
<Route path={"/admin/group/:groupId"} element={<EditGroupView {...controlObj} />}/>
<Route path={"/admin/logs"} element={<LogView {...controlObj} />}/>
<Route path={"/admin/permissions"} element={<AccessControlList {...controlObj} />}/>
<Route path={"/admin/routes"} element={<RouteListView {...controlObj} />}/>
<Route path={"/admin/routes/:routeId"} element={<RouteEditView {...controlObj} />}/>
<Route path={"/admin/settings"} element={<SettingsView {...controlObj} />}/>
<Route path={"/admin/profile"} element={<ProfileView {...controlObj} />}/>
<Route path={"*"} element={<View404 />} />
</Routes>
</Suspense>
<Footer info={info} />
</Sidebar>
<Dialog {...dialog}/>
</BrowserRouter>
}

@ -1,9 +1,8 @@
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
import API from "shared/api";
import Icon from "shared/elements/icon";
import LoginForm from "shared/views/login";
import {Alert} from "@mui/lab";
import {Button} from "@mui/material";
import {Button, CircularProgress} from "@mui/material";
import {LocaleContext} from "shared/locale";
import AdminDashboard from "./AdminDashboard";
@ -103,7 +102,7 @@ export default function App() {
</Button>
</Alert>
} else {
return <b>{L("general.loading")} <Icon icon={"spinner"}/></b>
return <b>{L("general.loading")} <CircularProgress /></b>
}
} else if (!user || !api.loggedIn || (api.user.twoFactorToken?.confirmed && !api.user.twoFactorToken.authenticated)) {
return <LoginForm api={api} info={info} onLogin={fetchUser} onLogout={onLogout} />

@ -1,11 +1,20 @@
import React from "react";
import {Divider, styled} from "@mui/material";
const StyledFooter = styled("footer")((props) => ({
position: "fixed",
bottom: 0,
right: 0,
backgroundColor: "white",
paddingTop: props.theme.spacing(1),
paddingRight: props.theme.spacing(1),
paddingLeft: props.theme.spacing(1),
}));
export default function Footer(props) {
return (
<footer className={"main-footer"}>
Theme: <strong>Copyright © 2014-2021 <a href={"https://adminlte.io"}>AdminLTE.io</a>. <b>Version</b> 3.2.0</strong>&nbsp;
Framework: <strong><a href={"https://git.romanh.de/Projekte/web-base"}>WebBase</a></strong>. <b>Version</b> {props.info.version}
</footer>
)
return <StyledFooter>
<Divider />
<b>Framework</b>: <a href={"https://git.romanh.de/Projekte/web-base"} target={"_blank"}>WebBase</a>&nbsp;Version {props.info.version}
</StyledFooter>
}

@ -1,57 +0,0 @@
import * as React from "react";
import {Link} from "react-router-dom";
import Icon from "shared/elements/icon";
export default function Header(props) {
const parent = {
api: props.api
};
function onToggleSidebar() {
let classes = document.body.classList;
if (classes.contains("sidebar-collapse")) {
classes.remove("sidebar-collapse");
classes.add("sidebar-open");
} else {
classes.add("sidebar-collapse");
classes.remove("sidebar-add");
}
}
return (
<nav className={"main-header navbar navbar-expand navbar-white navbar-light"}>
{/*Left navbar links */}
<ul className={"navbar-nav"}>
<li className={"nav-item"}>
<a href={"#"} className={"nav-link"} role={"button"} onClick={onToggleSidebar}>
<Icon icon={"bars"}/>
</a>
</li>
<li className={"nav-item d-none d-sm-inline-block"}>
<Link to={"/admin/dashboard"} className={"nav-link"}>
Home
</Link>
</li>
</ul>
{/* SEARCH FORM */}
<form className={"form-inline ml-3"}>
<div className={"input-group input-group-sm"}>
<input className={"form-control form-control-navbar"} type={"search"} placeholder={"Search"} aria-label={"Search"} />
<div className={"input-group-append"}>
<button className={"btn btn-navbar"} type={"submit"}>
<Icon icon={"search"}/>
</button>
</div>
</div>
</form>
{/* Right navbar links */}
<ul className={"navbar-nav ml-auto"}>
</ul>
</nav>
)
}

@ -1,30 +1,107 @@
import React, {useCallback, useContext} from 'react';
import {Link, NavLink} from "react-router-dom";
import Icon from "shared/elements/icon";
import React, {useCallback, useContext, useEffect, useState} from 'react';
import {Link, useNavigate} from "react-router-dom";
import {LocaleContext} from "shared/locale";
import {styled} from "@mui/material";
import {
Box, CssBaseline, Divider,
IconButton, List, ListItem, ListItemButton, ListItemIcon, ListItemText,
Select, Drawer,
styled, MenuItem, Menu,
} from "@mui/material";
import { Dropdown } from '@mui/base/Dropdown';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ProfilePicture from "shared/elements/profile-picture";
import {Dns, Groups, People, QueryStats, Security, Settings, Route, ArrowBack, Translate} from "@mui/icons-material";
import useCurrentPath from "shared/hooks/current-path";
const drawerWidth = 240;
const ProfileLink = styled(Link)((props) => ({
"& > div": {
padding: 3,
width: 30,
height: 30,
justifySelf: "center",
},
color: "inherit",
fontWeight: "bold",
marginTop: props.theme.spacing(1),
display: "grid",
gridTemplateColumns: "35px auto",
"& > span": {
alignSelf: "center",
marginLeft: props.theme.spacing(1)
},
}));
const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: "flex-start",
padding: theme.spacing(0, 1),
...theme.mixins.toolbar,
"& > button": {
display: 'flex',
marginLeft: "auto",
},
"& > img": {
width: 30,
height: 30,
},
marginLeft: props.theme.spacing(1),
marginTop: props.theme.spacing(1),
display: "grid",
gridTemplateColumns: "45px auto",
"& > span": {
alignSelf: "center"
marginLeft: theme.spacing(2),
fontSize: "1.5em",
}
}));
const openedMixin = (theme) => ({
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
overflowX: 'hidden',
});
const closedMixin = (theme) => ({
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: 'hidden',
width: `calc(${theme.spacing(7)} + 1px)`,
[theme.breakpoints.up('sm')]: {
width: `calc(${theme.spacing(8)} + 1px)`,
},
});
const StyledDrawer = styled(Drawer, { shouldForwardProp: (prop) => prop !== 'open' })(
({ theme, open }) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
...(open && {
...openedMixin(theme),
'& .MuiDrawer-paper': openedMixin(theme),
}),
...(!open && {
...closedMixin(theme),
'& .MuiDrawer-paper': closedMixin(theme),
}),
}),
);
export default function Sidebar(props) {
const api = props.api;
const showDialog = props.showDialog;
const {translate: L} = useContext(LocaleContext);
const {translate: L, currentLocale, setLanguageByCode} = useContext(LocaleContext);
const [languages, setLanguages] = useState(null);
const [fetchLanguages, setFetchLanguages] = useState(true);
const [drawerOpen, setDrawerOpen] = useState(window.screen.width >= 1000);
const [anchorEl, setAnchorEl] = useState(null);
const navigate = useNavigate();
const currentPath = useCurrentPath();
const onLogout = useCallback(() => {
api.logout().then(obj => {
@ -36,99 +113,156 @@ export default function Sidebar(props) {
});
}, [api, showDialog]);
const onSetLanguage = useCallback((code) => {
setLanguageByCode(api, code).then((res) => {
if (!res.success) {
showDialog(res.msg, L("general.error_language_set"));
}
});
}, [api, showDialog]);
const onFetchLanguages = useCallback((force = false) => {
if (force || fetchLanguages) {
setFetchLanguages(false);
api.getLanguages().then((res) => {
if (res.success) {
setLanguages(res.languages);
} else {
setLanguages({});
showDialog(res.msg, L("general.error_language_fetch"));
}
});
}
}, [api, fetchLanguages, showDialog]);
useEffect(() => {
onFetchLanguages();
}, []);
const menuItems = {
"dashboard": {
"name": "admin.dashboard",
"icon": "tachometer-alt"
"icon": <QueryStats />
},
"users": {
"name": "admin.users",
"icon": "users"
"icon": <People />
},
"groups": {
"name": "admin.groups",
"icon": "users-cog"
"icon": <Groups />
},
"routes": {
"name": "admin.page_routes",
"icon": "copy",
"icon": <Route />
},
"settings": {
"name": "admin.settings",
"icon": "tools"
"icon": <Settings />
},
"permissions": {
"name": "admin.acl",
"icon": "door-open"
"icon": <Security />
},
"logs": {
"name": "admin.logs",
"icon": "file-medical-alt"
},
"help": {
"name": "admin.help",
"icon": "question-circle"
"icon": <Dns />
},
};
let li = [];
for (let id in menuItems) {
let obj = menuItems[id];
const badge = (obj.badge ? <span className={"right badge badge-" + obj.badge.type}>{obj.badge.value}</span> : <></>);
li.push(
<li key={id} className={"nav-item"}>
<NavLink to={"/admin/" + id} className={"nav-link"}>
<Icon icon={obj.icon} className={"nav-icon"} /><p>{L(obj.name)}{badge}</p>
</NavLink>
</li>
);
const NavbarItem = (props) => <ListItem disablePadding sx={{ display: 'block' }}>
<ListItemButton onClick={props.onClick} selected={props.active} sx={{
minHeight: 48,
justifyContent: drawerOpen ? 'initial' : 'center',
px: 2.5,
}}>
<ListItemIcon sx={{
minWidth: 0,
mr: drawerOpen ? 2 : 'auto',
justifyContent: 'center',
}}>
{props.icon}
</ListItemIcon>
<ListItemText primary={L(props.name)} sx={{ display: drawerOpen ? "block" : "none" }} />
</ListItemButton>
</ListItem>
let li = [];
for (const [id, menuItem] of Object.entries(menuItems)) {
const match= /^\/admin\/(.*)$/.exec(currentPath);
const active = match?.length >= 2 && match[1] === id;
li.push(<NavbarItem key={id} {...menuItem} active={active} onClick={() => navigate(`/admin/${id}`)} />);
}
li.push(<li className={"nav-item"} key={"logout"}>
<a href={"#"} onClick={() => onLogout()} className={"nav-link"}>
<Icon icon={"arrow-left"} className={"nav-icon"} />
<p>{L("general.logout")}</p>
</a>
</li>);
li.push(<NavbarItem key={"logout"} name={"general.logout"} icon={<ArrowBack />} onClick={onLogout}/>);
return <>
<aside className={"main-sidebar sidebar-dark-primary elevation-4"}>
<Link href={"#"} className={"brand-link"} to={"/admin/dashboard"}>
<img src={"/img/icons/logo.png"} alt={"Logo"} className={"brand-image img-circle elevation-3"} style={{opacity: ".8"}} />
<span className={"brand-text font-weight-light ml-2"}>WebBase</span>
</Link>
<div className={"sidebar os-host os-theme-light os-host-overflow os-host-overflow-y os-host-resize-disabled os-host-scrollbar-horizontal-hidden os-host-transition"}>
{/* IDK what this is */}
<div className={"os-resize-observer-host"}>
<div className={"os-resize-observer observed"} style={{left: "0px", right: "auto"}}/>
</div>
<div className={"os-size-auto-observer"} style={{height: "calc(100% + 1px)", float: "left"}}>
<div className={"os-resize-observer observed"}/>
</div>
<div className={"os-content-glue"} style={{margin: "0px -8px"}}/>
<div className={"os-padding"}>
<div className={"os-viewport os-viewport-native-scrollbars-invisible"} style={{right: "0px", bottom: "0px"}}>
<div className={"os-content"} style={{padding: "0px 0px", height: "100%", width: "100%"}}>
<div className="user-panel mt-3 pb-3 mb-3 d-flex">
<div className="info">
<div className={"d-block text-light"}>{L("account.logged_in_as")}:</div>
<ProfileLink to={"/admin/profile"}>
<ProfilePicture user={api.user} />
<span>{api.user?.name || L("account.not_logged_in")}</span>
</ProfileLink>
</div>
</div>
<nav className={"mt-2"}>
<ul className={"nav nav-pills nav-sidebar flex-column"} data-widget={"treeview"} role={"menu"} data-accordion={"false"}>
{li}
</ul>
</nav>
</div>
</div>
</div>
</div>
</aside>
</>
return <Box sx={{ display: 'flex' }}>
<CssBaseline />
<StyledDrawer variant={"permanent"} open={drawerOpen}>
<DrawerHeader>
{drawerOpen && <>
<img src={"/img/icons/logo.png"} alt={"Logo"} />
<span>WebBase</span>
</>}
<IconButton onClick={() => setDrawerOpen(!drawerOpen)}>
{drawerOpen ? <ChevronLeftIcon/> : <ChevronRightIcon/>}
</IconButton>
</DrawerHeader>
<Divider/>
<ListItem sx={{display: 'block'}}>
<Box sx={{opacity: drawerOpen ? 1 : 0}}>{L("account.logged_in_as")}:</Box>
<ProfileLink to={"/admin/profile"}>
<ProfilePicture user={api.user}/>
{drawerOpen && <span>{api.user?.name || L("account.not_logged_in")}</span>}
</ProfileLink>
</ListItem>
<Divider/>
<List>
{li}
</List>
<Divider/>
<ListItem sx={{display: 'block'}}>
{ drawerOpen ?
<Select native value={currentLocale} size={"small"} fullWidth={true}
onChange={e => onSetLanguage(e.target.value)}>
{Object.values(languages || {}).map(language =>
<option key={language.code} value={language.code}>
{language.name}
</option>)
}
</Select>
: <ListItemButton sx={{
minHeight: 48,
justifyContent: 'center',
px: 2.5,
}}>
<Dropdown>
<ListItemIcon onClick={e => setAnchorEl(e.currentTarget)} sx={{
minWidth: 0,
mr: 'auto',
justifyContent: 'center',
}}>
<Translate />
</ListItemIcon>
<Menu open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
onClick={() => setAnchorEl(null)}
transformOrigin={{ horizontal: 'left', vertical: 'bottom' }}
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}>
{Object.values(languages || {}).map(language =>
<MenuItem key={language.code} onClick={() => onSetLanguage(language.code)}>
{language.name}
</MenuItem>)
}
</Menu>
</Dropdown>
</ListItemButton>
}
</ListItem>
</StyledDrawer>
<Box component="main" sx={{flexGrow: 1, p: 1}}>
{props.children}
</Box>
</Box>
}

@ -0,0 +1,31 @@
import {Box, Breadcrumbs, Grid, styled} from "@mui/material";
const StyledViewContent = styled(Box)((props) => ({
padding: props.theme.spacing(2),
}))
const StyledNavigation = styled(Grid)((props) => ({
alignSelf: "end",
"& ol": {
justifyContent: "end",
margin: "auto"
}
}));
export default function ViewContent(props) {
const {title, path, children, ...other} = props;
return <StyledViewContent {...other}>
<Grid container>
<Grid item xs={6}>
<h2>{title}</h2>
</Grid>
<StyledNavigation item xs={6}>
<Breadcrumbs>{path}</Breadcrumbs>
</StyledNavigation>
</Grid>
{children}
</StyledViewContent>
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -12,11 +12,14 @@ import {
TableContainer,
TableHead,
TableRow,
IconButton, styled, FormGroup, FormLabel, Box
IconButton, styled, FormGroup, FormLabel, Box, Grid
} from "@mui/material";
import {Add, Delete, Edit, Refresh} from "@mui/icons-material";
import {USER_GROUP_ADMIN} from "shared/constants";
import Dialog from "shared/elements/dialog";
import ViewContent from "../elements/view-content";
import ButtonBar from "../elements/button-bar";
import TableBodyStriped from "shared/elements/table-body-striped";
const BorderedColumn = styled(TableCell)({
borderLeft: "1px dotted #666",
@ -206,73 +209,68 @@ export default function AccessControlList(props) {
return <>{rows}</>
}
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"}>{L("permissions.title")}</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">{L("permissions.title_short")}</li>
</ol>
</div>
</div>
</div>
</div>
<div className={"row"}>
<FormGroup className={"col-6"}>
<FormLabel>{L("permissions.search")}</FormLabel>
<TextField
placeholder={L("permissions.query") + "…"}
value={query}
onChange={e => setQuery(e.target.value)}
variant={"outlined"}
size={"small"} />
</FormGroup>
<div className={"col-6 text-right"}>
return <ViewContent title={L("permissions.title")} path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<span key={"permissions"}>{L("permissions.title_short")}</span>,
]}>
<Grid container>
<Grid item xs={6}>
<FormGroup>
<FormLabel>{L("permissions.search")}</FormLabel>
<TextField
placeholder={L("permissions.query") + "…"}
value={query}
onChange={e => setQuery(e.target.value)}
variant={"outlined"}
size={"small"}/>
</FormGroup>
</Grid>
<Grid item xs={6} textAlign={"end"}>
<Box>
<FormLabel>{L("general.controls")}</FormLabel>
</Box>
<Box mb={2}>
<Button variant={"outlined"} color={"primary"} className={"mr-1"} size={"small"}
startIcon={<Refresh />} onClick={() => onFetchACL(true)}>
<ButtonBar mb={2}>
<Button variant={"outlined"} color={"primary"} size={"small"}
startIcon={<Refresh/>} onClick={() => onFetchACL(true)}>
{L("general.reload")}
</Button>
<Button variant={"outlined"} startIcon={<Add />} size={"small"}
<Button variant={"outlined"} startIcon={<Add/>} size={"small"}
disabled={!props.api.hasGroup(USER_GROUP_ADMIN)}
onClick={() => setDialogData({
open: true,
title: L("permissions.add_permission"),
inputs: [
{ type: "label", value: L("permissions.method") + ":" },
{ type: "text", name: "method", value: "", placeholder: L("permissions.method") },
{ type: "label", value: L("permissions.description") + ":" },
{ type: "text", name: "description", maxLength: 128, placeholder: L("permissions.description") }
{type: "label", value: L("permissions.method") + ":"},
{type: "text", name: "method", value: "", placeholder: L("permissions.method")},
{type: "label", value: L("permissions.description") + ":"},
{
type: "text",
name: "description",
maxLength: 128,
placeholder: L("permissions.description")
}
],
onOption: (option, inputData) => option === 0 ? onUpdatePermission(inputData, []) : true
})} >
})}>
{L("general.add")}
</Button>
</Box>
</div>
</div>
</ButtonBar>
</Grid>
</Grid>
<TableContainer component={Paper} style={{overflowX: "initial"}}>
<Table stickyHeader size={"small"} className={"table-striped"}>
<Table stickyHeader size={"small"}>
<TableHead>
<TableRow>
<TableCell>{L("permissions.permission")}</TableCell>
<BorderedColumn align={"center"}><i>{L("permissions.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}
</TableCell>) }
</TableCell>)}
</TableRow>
</TableHead>
<TableBody>
<PermissionList />
</TableBody>
<TableBodyStriped>
<PermissionList/>
</TableBodyStriped>
</Table>
</TableContainer>
<Dialog show={dialogData.open}
@ -281,6 +279,6 @@ export default function AccessControlList(props) {
message={dialogData.message}
onOption={dialogData.onOption}
inputs={dialogData.inputs}
options={[L("general.ok"), L("general.cancel")]} />
</>
options={[L("general.ok"), L("general.cancel")]}/>
</ViewContent>
}

@ -8,10 +8,12 @@ import {DataTable, ControlsColumn, NumericColumn, StringColumn} from "shared/ele
import EditIcon from "@mui/icons-material/Edit";
import usePagination from "shared/hooks/pagination";
import Dialog from "shared/elements/dialog";
import {FormControl, FormGroup, FormLabel, TextField, Button, CircularProgress, Box} from "@mui/material";
import {FormControl, FormLabel, TextField, Button, CircularProgress, Box, Grid} from "@mui/material";
import {Add, Delete, KeyboardArrowLeft, Save} from "@mui/icons-material";
import {MuiColorInput} from "mui-color-input";
import ButtonBar from "../../elements/button-bar";
import ViewContent from "../../elements/view-content";
import FormGroup from "../../elements/form-group";
const defaultGroupData = {
name: "",
@ -175,91 +177,75 @@ export default function EditGroupView(props) {
}
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"}>
{ isNewGroup ? L("account.new_group") : L("account.group") + ": " + group.name }
</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"><Link to={"/admin/groups"}>{L("account.group")}</Link></li>
<li className="breadcrumb-item active">{ isNewGroup ? L("general.new") : groupId }</li>
</ol>
</div>
</div>
</div>
</div>
<div className={"content"}>
<div className={"row"}>
<div className={"col-4 pl-5 pr-5"}>
<FormGroup className={"my-2"}>
<FormLabel htmlFor={"name"}>
{L("account.group_name")}
</FormLabel>
<FormControl>
<TextField maxLength={32} value={group.name}
size={"small"} name={"name"}
placeholder={L("account.name")}
onChange={e => setGroup({...group, name: e.target.value})}/>
</FormControl>
</FormGroup>
<ViewContent title={ isNewGroup ? L("account.new_group") : L("account.group") + ": " + group.name }
path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<Link key={"group"} to={"/admin/groups"}>{L("account.group")}</Link>,
<span key={"action"} >{isNewGroup ? L("general.new") : groupId}</span>,
]}>
<Grid container>
<Grid item xs={6}>
<FormGroup>
<FormLabel htmlFor={"name"}>
{L("account.group_name")}
</FormLabel>
<FormControl>
<TextField maxLength={32} value={group.name}
size={"small"} name={"name"}
placeholder={L("account.name")}
onChange={e => setGroup({...group, name: e.target.value})}/>
</FormControl>
</FormGroup>
<FormGroup className={"my-2"}>
<FormLabel htmlFor={"color"}>
{L("account.color")}
</FormLabel>
<FormControl>
<MuiColorInput
format={"hex"}
value={group.color}
size={"small"}
variant={"outlined"}
onChange={color => setGroup({...group, color: color})}
/>
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel htmlFor={"color"}>
{L("account.color")}
</FormLabel>
<FormControl>
<MuiColorInput
format={"hex"}
value={group.color}
size={"small"}
variant={"outlined"}
onChange={color => setGroup({...group, color: color})}
/>
</FormControl>
</FormGroup>
<ButtonBar mt={2}>
<Button startIcon={<KeyboardArrowLeft />}
variant={"outlined"}
onClick={() => navigate("/admin/groups")}>
{L("general.go_back")}
</Button>
<Button startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
color={"primary"}
variant={"outlined"}
disabled={isSaving || (!api.hasPermission(isNewGroup ? "groups/create" : "groups/update"))}
onClick={onSave}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
{ !isNewGroup &&
<Button startIcon={<Delete/>} disabled={!api.hasPermission("groups/delete")}
variant={"outlined"} color={"secondary"}
onClick={() => setDialogData({
open: true,
title: L("account.delete_group_title"),
message: L("account.delete_group_text"),
onOption: option => option === 0 ? onDeleteGroup() : true
})}>
{L("general.delete")}
</Button>
}
</ButtonBar>
</div>
</div>
<ButtonBar mt={2}>
<Button startIcon={<KeyboardArrowLeft />}
variant={"outlined"}
onClick={() => navigate("/admin/groups")}>
{L("general.go_back")}
</Button>
<Button startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
color={"primary"}
variant={"outlined"}
disabled={isSaving || (!api.hasPermission(isNewGroup ? "groups/create" : "groups/update"))}
onClick={onSave}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
{ !isNewGroup &&
<Button startIcon={<Delete/>} disabled={!api.hasPermission("groups/delete")}
variant={"outlined"} color={"secondary"}
onClick={() => setDialogData({
open: true,
title: L("account.delete_group_title"),
message: L("account.delete_group_text"),
onOption: option => option === 0 ? onDeleteGroup() : true
})}>
{L("general.delete")}
</Button>
}
</ButtonBar>
{!isNewGroup && api.hasPermission("groups/getMembers") ?
<Box m={3} className={"col-6"}>
<Box mt={3}>
<h4>{L("account.members")}</h4>
<DataTable
data={members}
pagination={pagination}
defaultSortOrder={"asc"}
defaultSortColumn={0}
className={"table table-striped"}
fetchData={onFetchMembers}
placeholder={L("account.no_members")}
columns={[
@ -298,7 +284,9 @@ export default function EditGroupView(props) {
</Box>
: <></>
}
</div>
</Grid>
</Grid>
</ViewContent>
<Dialog show={dialogData.open}
onClose={() => setDialogData({open: false})}
title={dialogData.title}
@ -306,6 +294,5 @@ export default function EditGroupView(props) {
onOption={dialogData.onOption}
inputs={dialogData.inputs}
options={[L("general.ok"), L("general.cancel")]} />
</>
</>
}

@ -4,6 +4,7 @@ import {LocaleContext} from "shared/locale";
import {ControlsColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
import {Add, Edit} from "@mui/icons-material";
import usePagination from "shared/hooks/pagination";
import ViewContent from "../../elements/view-content";
export default function GroupListView(props) {
@ -46,42 +47,25 @@ export default function GroupListView(props) {
]),
];
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"}>{L("account.groups")}</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">{L("account.groups")}</li>
</ol>
</div>
</div>
</div>
<div className={"content"}>
<div className={"container-fluid"}>
<DataTable
data={groups}
pagination={pagination}
defaultSortOrder={"asc"}
defaultSortColumn={0}
className={"table table-striped"}
fetchData={onFetchGroups}
placeholder={"No groups to display"}
columns={columnDefinitions}
buttons={[{
key: "btn-create-group",
color: "primary",
startIcon: <Add />,
onClick: () => navigate("/admin/group/new"),
disabled: !api.hasPermission("groups/create"),
children: L("general.create_new")
}]}/>
</div>
</div>
</div>
</>
return <ViewContent title={L("account.groups")} path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<span key={"groups"} >{L("account.groups")}</span>,
]}>
<DataTable
data={groups}
pagination={pagination}
defaultSortOrder={"asc"}
defaultSortColumn={0}
fetchData={onFetchGroups}
placeholder={"No groups to display"}
columns={columnDefinitions}
buttons={[{
key: "btn-create-group",
color: "primary",
startIcon: <Add />,
onClick: () => navigate("/admin/group/new"),
disabled: !api.hasPermission("groups/create"),
children: L("general.create_new")
}]}/>
</ViewContent>
}

@ -3,13 +3,14 @@ import {LocaleContext} from "shared/locale";
import {Link} from "react-router-dom";
import usePagination from "shared/hooks/pagination";
import {DataColumn, DataTable, DateTimeColumn, NumericColumn, StringColumn} from "shared/elements/data-table";
import {Box, FormControl, FormGroup, FormLabel, IconButton, MenuItem, TextField} from "@mui/material";
import {Box, FormControl, FormGroup, FormLabel, Grid, IconButton, MenuItem, TextField} from "@mui/material";
import {DateTimePicker} from "@mui/x-date-pickers";
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import {API_DATETIME_FORMAT} from "shared/constants";
import {format, toDate} from "date-fns";
import {ExpandLess, ExpandMore} from "@mui/icons-material";
import ViewContent from "../elements/view-content";
export default function LogView(props) {
@ -106,74 +107,63 @@ export default function LogView(props) {
messageColumn,
];
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"}>{L("logs.title")}</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">{L("logs.title")}</li>
</ol>
</div>
</div>
</div>
<div className={"content overflow-auto"}>
<div className={"row p-2"}>
<FormGroup className={"col-2"}>
<FormLabel>{L("logs.severity")}</FormLabel>
<FormControl>
<TextField select variant={"outlined"} size={"small"} value={logLevel}
onChange={e => setLogLevel(parseInt(e.target.value))}
inputProps={{ size: "small" }}>
{LOG_LEVELS.map((value, index) =>
<MenuItem key={"option-" + value} value={index}>{value}</MenuItem>)
}
</TextField>
</FormControl>
</FormGroup>
<FormGroup className={"col-4"}>
<FormLabel>{L("logs.timestamp")}</FormLabel>
<FormControl>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<DateTimePicker label={L("logs.timestamp_placeholder") + "…"}
value={timestamp ? toDate(new Date()) : null}
format={L("general.datefns_datetime_format_precise")}
onChange={(newValue) => setTimestamp(newValue)}
slotProps={{ textField: { size:'small' } }}
sx={{"& .MuiInputBase-input": { height: "23px", padding: 1 }}}
/>
</LocalizationProvider>
</FormControl>
</FormGroup>
<FormGroup className={"col-6"}>
<FormLabel>{L("logs.search")}</FormLabel>
<FormControl>
<TextField
placeholder={L("logs.search_query") + "…"}
value={query}
onChange={e => setQuery(e.target.value)}
variant={"outlined"}
size={"small"}/>
</FormControl>
</FormGroup>
</div>
<div className={"container-fluid"}>
<DataTable
data={logEntries}
pagination={pagination}
className={"table table-striped"}
fetchData={onFetchLogs}
forceReload={forceReload}
defaultSortColumn={3}
defaultSortOrder={"desc"}
placeholder={L("logs.no_entries_placeholder")}
columns={columnDefinitions} />
</div>
</div>
</div>
</>
return <ViewContent title={L("logs.title")} path={[
<Link key={"dashboard"} to={"/admin/dashboard"}>Home</Link>,
<span key={"logs"}>{L("logs.title")}</span>
]}>
<Grid container spacing={2}>
<Grid item xs={2}>
<FormGroup>
<FormLabel>{L("logs.severity")}</FormLabel>
<FormControl>
<TextField select variant={"outlined"} size={"small"} value={logLevel}
onChange={e => setLogLevel(parseInt(e.target.value))}
inputProps={{size: "small"}}>
{LOG_LEVELS.map((value, index) =>
<MenuItem key={"option-" + value} value={index}>{value}</MenuItem>)
}
</TextField>
</FormControl>
</FormGroup>
</Grid>
<Grid item xs={4}>
<FormGroup>
<FormLabel>{L("logs.timestamp")}</FormLabel>
<FormControl>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<DateTimePicker label={L("logs.timestamp_placeholder") + "…"}
value={timestamp ? toDate(new Date()) : null}
format={L("general.datefns_datetime_format_precise")}
onChange={(newValue) => setTimestamp(newValue)}
slotProps={{textField: {size: 'small'}}}
sx={{"& .MuiInputBase-input": {height: "23px", padding: 1}}}
/>
</LocalizationProvider>
</FormControl>
</FormGroup>
</Grid>
<Grid item xs={6}>
<FormGroup>
<FormLabel>{L("logs.search")}</FormLabel>
<FormControl>
<TextField
placeholder={L("logs.search_query") + "…"}
value={query}
onChange={e => setQuery(e.target.value)}
variant={"outlined"}
size={"small"}/>
</FormControl>
</FormGroup>
</Grid>
</Grid>
<DataTable
data={logEntries}
pagination={pagination}
fetchData={onFetchLogs}
forceReload={forceReload}
defaultSortColumn={3}
defaultSortOrder={"desc"}
placeholder={L("logs.no_entries_placeholder")}
columns={columnDefinitions}/>
</ViewContent>
}

@ -13,26 +13,65 @@ import {
LibraryBooks,
People
} from "@mui/icons-material";
import {Box, CircularProgress, Paper, Table, TableBody, TableCell, TableRow} from "@mui/material";
import {
Box,
CircularProgress,
Divider,
Grid,
Paper,
styled,
Table,
TableBody,
TableCell,
TableRow
} from "@mui/material";
import ViewContent from "../elements/view-content";
import {Alert} from "@mui/lab";
const StatBox = (props) => <div className={"col-lg-3 col-6"}>
<div className={"small-box bg-" + props.color}>
<div className={"inner"}>
const StyledStatBox = styled(Alert)((props) => ({
position: "relative",
padding: 0,
"& > div": {
padding: 0,
width: "100%",
"& a": {
color: "white",
},
"& div:nth-of-type(1)": {
padding: props.theme.spacing(2),
},
"& div:nth-of-type(2) > svg": {
position: "absolute",
top: props.theme.spacing(1),
right: props.theme.spacing(1),
opacity: 0.6,
fontSize: "5em"
},
"& div:nth-of-type(3)": {
backdropFilter: "brightness(70%)",
textAlign: "right",
padding: props.theme.spacing(0.5),
}
},
}));
const StatBox = (props) => <StyledStatBox variant={"filled"} icon={false}
severity={props.color}>
<Box>
{!isNaN(props.count) ?
<>
<h3>{props.count}</h3>
<h2>{props.count}</h2>
<p>{props.text}</p>
</> : <CircularProgress variant={"determinate"} />
}
</div>
<div className={"icon"}>
{props.icon}
</div>
<Link to={props.link} className={"small-box-footer text-right p-1"}>
More info <ArrowCircleRight />
</Link>
</div>
</div>
</Box>
<Box>{props.icon}</Box>
<Box>
<Link to={props.link}>
More info <ArrowCircleRight />
</Link>
</Box>
</StyledStatBox>
const StatusLine = (props) => {
const {enabled, text, ...other} = props;
@ -84,96 +123,88 @@ export default function Overview(props) {
loadAvg = loadAvg.map(v => sprintf("%.1f", v)).join(", ");
}
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"}>{L("admin.dashboard")}</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">{L("admin.dashboard")}</li>
</ol>
</div>
</div>
</div>
</div>
<section className={"content"}>
<div className={"container-fluid"}>
<div className={"row"}>
<StatBox color={"info"} count={stats?.userCount}
text={L("admin.users_registered")}
icon={<People />}
link={"/admin/users"} />
<StatBox color={"success"} count={stats?.groupCount}
text={L("admin.available_groups")}
icon={<Groups />}
link={"/admin/groups"} />
<StatBox color={"warning"} count={stats?.pageCount}
text={L("admin.routes_defined")}
icon={<LibraryBooks />}
link={"/admin/routes"} />
<StatBox color={"danger"} count={stats?.errorCount}
text={L("admin.error_count")}
icon={<BugReport />}
link={"/admin/logs"} />
</div>
</div>
<Box m={2} p={2} component={Paper}>
<h4>Server Stats</h4><hr />
{stats === null ? <CircularProgress /> :
<Table>
<TableBody>
<TableRow>
<TableCell>Web-Base Version</TableCell>
<TableCell>{stats.server.version}</TableCell>
</TableRow>
<TableRow>
<TableCell>Server</TableCell>
<TableCell>{stats.server.server ?? "Unknown"}</TableCell>
</TableRow>
<TableRow>
<TableCell>Load Average</TableCell>
<TableCell>{loadAvg}</TableCell>
</TableRow>
<TableRow>
<TableCell>Memory Usage</TableCell>
<TableCell>{humanReadableSize(stats.server.memoryUsage)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Database</TableCell>
<TableCell>{stats.server.database}</TableCell>
</TableRow>
<TableRow>
<TableCell>Captcha</TableCell>
<TableCell>
<StatusLine enabled={!!stats.server.captcha}
text={L("settings." + (stats.server.captcha ? stats.server.captcha.name : "disabled"))}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Mail</TableCell>
<TableCell>
<StatusLine enabled={!!stats.server.mail}
text={L("settings." + (stats.server.mail ? "enabled" : "disabled"))}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Rate-Limiting</TableCell>
<TableCell>
<StatusLine enabled={!!stats.server.rateLimiting}
text={L("settings." + (stats.server.rateLimiting ? "enabled" : "disabled"))}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
}
</Box>
</section>
</>
return <ViewContent title={L("admin.dashboard")} path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>
]}>
<Grid container spacing={2}>
<Grid item xs={3}>
<StatBox color={"info"} count={stats?.userCount}
text={L("admin.users_registered")}
icon={<People/>}
link={"/admin/users"}/>
</Grid>
<Grid item xs={3}>
<StatBox color={"success"} count={stats?.groupCount}
text={L("admin.available_groups")}
icon={<Groups/>}
link={"/admin/groups"}/>
</Grid>
<Grid item xs={3}>
<StatBox color={"warning"} count={stats?.pageCount}
text={L("admin.routes_defined")}
icon={<LibraryBooks/>}
link={"/admin/routes"}/>
</Grid>
<Grid item xs={3}>
<StatBox color={"error"} count={stats?.errorCount}
text={L("admin.error_count")}
icon={<BugReport />}
link={"/admin/logs"}/>
</Grid>
</Grid>
<Box m={2} p={2} component={Paper}>
<h4>Server Stats</h4>
<Divider />
{stats === null ? <CircularProgress/> :
<Table>
<TableBody>
<TableRow>
<TableCell>Web-Base Version</TableCell>
<TableCell>{stats.server.version}</TableCell>
</TableRow>
<TableRow>
<TableCell>Server</TableCell>
<TableCell>{stats.server.server ?? "Unknown"}</TableCell>
</TableRow>
<TableRow>
<TableCell>Load Average</TableCell>
<TableCell>{loadAvg}</TableCell>
</TableRow>
<TableRow>
<TableCell>Memory Usage</TableCell>
<TableCell>{humanReadableSize(stats.server.memoryUsage)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Database</TableCell>
<TableCell>{stats.server.database}</TableCell>
</TableRow>
<TableRow>
<TableCell>Captcha</TableCell>
<TableCell>
<StatusLine enabled={!!stats.server.captcha}
text={L("settings." + (stats.server.captcha ? stats.server.captcha.name : "disabled"))}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Mail</TableCell>
<TableCell>
<StatusLine enabled={!!stats.server.mail}
text={L("settings." + (stats.server.mail ? "enabled" : "disabled"))}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Rate-Limiting</TableCell>
<TableCell>
<StatusLine enabled={!!stats.server.rateLimiting}
text={L("settings." + (stats.server.rateLimiting ? "enabled" : "disabled"))}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
}
</Box>
</ViewContent>
}

@ -46,7 +46,7 @@ export default function ChangePasswordBox(props) {
onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
</FormControl>
</SpacedFormGroup>
<Box className={"w-50"}>
<Box sx={{width: "30%", minWidth: 300}}>
<PasswordStrength password={changePassword.new} minLength={6} />
</Box>
</CollapseBox>

@ -18,6 +18,7 @@ import ChangePasswordBox from "./change-password-box";
import GpgBox from "./gpg-box";
import MultiFactorBox from "./mfa-box";
import EditProfilePicture from "./edit-picture";
import ViewContent from "../../elements/view-content";
export default function ProfileView(props) {
@ -78,24 +79,10 @@ export default function ProfileView(props) {
}, [profile, changePassword, api, showDialog, isSaving]);
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"}>
{L("account.edit_profile")}
</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">{L("account.profile")}</li>
</ol>
</div>
</div>
</div>
</div>
<Box>
<ViewContent title={L("account.edit_profile")} path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<span key={"profile"}>{L("account.profile")}</span>
]}>
<Box display={"grid"} gridTemplateColumns={"300px auto"}>
<EditProfilePicture api={api} showDialog={showDialog} setProfile={setProfile}
profile={profile} setDialogData={setDialogData} />
@ -104,9 +91,9 @@ export default function ProfileView(props) {
<FormLabel>{L("account.username")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
value={profile.name}
onChange={e => setProfile({...profile, name: e.target.value })} />
size={"small"}
value={profile.name}
onChange={e => setProfile({...profile, name: e.target.value })} />
</FormControl>
</SpacedFormGroup>
<SpacedFormGroup>
@ -148,14 +135,13 @@ export default function ProfileView(props) {
<Box mt={2}>
<Button variant={"outlined"} color={"primary"}
disabled={isSaving || !api.hasPermission("user/updateProfile")}
startIcon={isSaving ? <CircularProgress size={12} /> : <Save />}
onClick={onUpdateProfile}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
disabled={isSaving || !api.hasPermission("user/updateProfile")}
startIcon={isSaving ? <CircularProgress size={12} /> : <Save />}
onClick={onUpdateProfile}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
</Box>
</Box>
</ViewContent>
<Dialog show={dialogData.show}
title={dialogData.title}
message={dialogData.message}

@ -11,6 +11,7 @@ import * as React from "react";
import RouteForm from "./route-form";
import {KeyboardArrowLeft, Save} from "@mui/icons-material";
import ButtonBar from "../../elements/button-bar";
import ViewContent from "../../elements/view-content";
const MonoSpaceTextField = styled(TextField)((props) => ({
"& input": {
@ -113,42 +114,35 @@ export default function RouteEditView(props) {
return <CircularProgress/>
}
return <div className={"content-header"}>
<div className={"container-fluid"}>
<ol className={"breadcrumb"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active"><Link to={"/admin/routes"}>{L("routes.title")}</Link></li>
<li className="breadcrumb-item active">{isNewRoute ? L("general.new") : L("general.edit")}</li>
</ol>
</div>
<div className={"content"}>
<div className={"container-fluid"}>
<h3>{L(isNewRoute ? "routes.create_route_title" : "routes.edit_route_title")}</h3>
</div>
</div>
<RouteForm route={route} setRoute={setRoute} />
<ButtonBar mt={2}>
<Button startIcon={<KeyboardArrowLeft />}
variant={"outlined"}
onClick={() => navigate("/admin/routes")}>
{L("general.cancel")}
</Button>
<Button startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
color={"primary"}
variant={"outlined"}
disabled={isSaving}
onClick={onSave}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
</ButtonBar>
<Box mt={3}>
<h5>{L("routes.validate_route")}</h5>
<MonoSpaceTextField value={routeTest} onChange={e => setRouteTest(e.target.value)}
variant={"outlined"} size={"small"} fullWidth={true}
placeholder={L("routes.validate_route_placeholder") + "…"} />
<pre>
Match: {JSON.stringify(routeTestResult)}
</pre>
</Box>
</div>
return <ViewContent title={L(isNewRoute ? "routes.create_route_title" : "routes.edit_route_title")}
path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<Link key={"routes"} to={"/admin/routes"}>{L("routes.title")}</Link>,
<span key={"action"}>{isNewRoute ? L("general.new") : L("general.edit")}</span>,
]}>
<RouteForm route={route} setRoute={setRoute} />
<ButtonBar mt={2}>
<Button startIcon={<KeyboardArrowLeft />}
variant={"outlined"}
onClick={() => navigate("/admin/routes")}>
{L("general.cancel")}
</Button>
<Button startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
color={"primary"}
variant={"outlined"}
disabled={isSaving}
onClick={onSave}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
</ButtonBar>
<Box mt={3}>
<h5>{L("routes.validate_route")}</h5>
<MonoSpaceTextField value={routeTest} onChange={e => setRouteTest(e.target.value)}
variant={"outlined"} size={"small"} fullWidth={true}
placeholder={L("routes.validate_route_placeholder") + "…"} />
<pre>
Match: {JSON.stringify(routeTestResult)}
</pre>
</Box>
</ViewContent>
}

@ -9,12 +9,15 @@ import {
TableHead,
TableRow,
Button,
IconButton, Checkbox
IconButton, Checkbox, Box
} from "@mui/material";
import {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale";
import {Add, Cached, Delete, Edit, Refresh} from "@mui/icons-material";
import Dialog from "shared/elements/dialog";
import ViewContent from "../../elements/view-content";
import ButtonBar from "../../elements/button-bar";
import TableBodyStriped from "shared/elements/table-body-striped";
const RouteTableRow = styled(TableRow)((props) => ({
"& td": {
@ -22,7 +25,6 @@ const RouteTableRow = styled(TableRow)((props) => ({
}
}));
export default function RouteListView(props) {
// meta
@ -118,101 +120,89 @@ export default function RouteListView(props) {
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"}>{L("routes.title")}</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">{L("routes.title")}</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"} size={"small"}
startIcon={<Refresh />} onClick={() => onFetchRoutes(true)}>
{L("general.reload")}
</Button>
<Button variant={"outlined"} className={"m-1"} startIcon={<Add />} size={"small"}
disabled={!props.api.hasPermission("routes/add")}
onClick={() => navigate("/admin/routes/new")} >
{L("general.add")}
</Button>
<Button variant={"outlined"} className={"m-1"} startIcon={<Cached />} size={"small"}
disabled={!props.api.hasPermission("routes/generateCache") || isGeneratingCache}
onClick={onRegenerateCache} >
{isGeneratingCache ? L("routes.regenerating_cache") + "…" : L("routes.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("routes.route")}</TableCell>
<TableCell>{L("routes.type")}</TableCell>
<TableCell>{L("routes.target")}</TableCell>
<TableCell>{L("routes.extra")}</TableCell>
<TableCell align={"center"}>{L("routes.active")}</TableCell>
<TableCell align={"center"}>{L("routes.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("routes.delete_route_title"),
message: L("routes.delete_route_text"),
inputs: [
{ type: "text", name: "pattern", value: route.pattern, disabled: true}
],
options: [L("general.ok"), L("general.cancel")],
onOption: btn => btn === 0 ? onDeleteRoute(route.id) : true
})}>
<Delete />
</IconButton>
</TableCell>
</RouteTableRow>
)}
</TableBody>
</Table>
</TableContainer>
<ViewContent title={L("routes.title")} path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<span key={"routes"}>{L("routes.title")}</span>
]}>
<ButtonBar mb={1}>
<Button variant={"outlined"} color={"primary"} size={"small"}
startIcon={<Refresh />} onClick={() => onFetchRoutes(true)}>
{L("general.reload")}
</Button>
<Button variant={"outlined"} startIcon={<Add />} size={"small"}
disabled={!props.api.hasPermission("routes/add")}
onClick={() => navigate("/admin/routes/new")} >
{L("general.add")}
</Button>
<Button variant={"outlined"} startIcon={<Cached />} size={"small"}
disabled={!props.api.hasPermission("routes/generateCache") || isGeneratingCache}
onClick={onRegenerateCache} >
{isGeneratingCache ? L("routes.regenerating_cache") + "…" : L("routes.regenerate_cache")}
</Button>
</ButtonBar>
<TableContainer component={Paper} sx={{overflowX: "initial"}}>
<Table stickyHeader size={"small"}>
<TableHead>
<TableRow>
<TableCell>{L("general.id")}</TableCell>
<TableCell>{L("routes.route")}</TableCell>
<TableCell>{L("routes.type")}</TableCell>
<TableCell>{L("routes.target")}</TableCell>
<TableCell>{L("routes.extra")}</TableCell>
<TableCell align={"center"}>{L("routes.active")}</TableCell>
<TableCell align={"center"}>{L("routes.exact")}</TableCell>
<TableCell align={"center"}>{L("general.controls")}</TableCell>
</TableRow>
</TableHead>
<TableBodyStriped>
{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("routes.delete_route_title"),
message: L("routes.delete_route_text"),
inputs: [
{ type: "text", name: "pattern", value: route.pattern, disabled: true}
],
options: [L("general.ok"), L("general.cancel")],
onOption: btn => btn === 0 ? onDeleteRoute(route.id) : true
})}>
<Delete />
</IconButton>
</TableCell>
</RouteTableRow>
)}
</TableBodyStriped>
</Table>
</TableContainer>
</ViewContent>
<Dialog show={dialogData.open}
onClose={() => { setDialogData({open: false}); dialogData.onClose && dialogData.onClose() }}
onClose={() => {
setDialogData({open: false});
dialogData.onClose && dialogData.onClose()
}}
title={dialogData.title}
message={dialogData.message}
onOption={dialogData.onOption}

@ -17,7 +17,7 @@ import {
import {Link} from "react-router-dom";
import {
Add,
Delete,
Delete, DownloadDone,
LibraryBooks,
Mail,
RestartAlt,
@ -33,6 +33,7 @@ import SettingsNumberInput from "./input-number";
import SettingsPasswordInput from "./input-password";
import SettingsTextInput from "./input-text";
import SettingsSelection from "./input-selection";
import ViewContent from "../../elements/view-content";
export default function SettingsView(props) {
@ -197,6 +198,16 @@ export default function SettingsView(props) {
}
}, [api, showDialog, testMailAddress, isSending]);
const onTestRedis = useCallback(data => {
api.testRedis().then(data => {
if (!data.success) {
showDialog(data.msg, L("settings.redis_test_error"));
} else {
showDialog(L("settings.redis_test_success"), L("general.success"));
}
});
}, [api, showDialog]);
const onReset = useCallback(() => {
setFetchSettings(true);
setNewKey("");
@ -266,7 +277,8 @@ export default function SettingsView(props) {
<Button startIcon={isSending ? <CircularProgress size={14} /> : <Send />}
variant={"outlined"} onClick={onSendTestMail}
fullWidth={true}
disabled={!settings.mail_enabled || isSending || !api.hasPermission("mail/test")}>
disabled={!settings.mail_enabled || isSending || !api.hasPermission("mail/test") || hasChanged}
title={hasChanged ? L("general.unsaved_changes") : ""}>
{isSending ? L("general.sending") + "…" : L("general.send")}
</Button>
</Grid>
@ -300,6 +312,12 @@ export default function SettingsView(props) {
renderTextInput("redis_host", !settings.rate_limiting_enabled),
renderNumberInput("redis_port", 1, 65535, !settings.rate_limiting_enabled),
renderPasswordInput("redis_password", !settings.rate_limiting_enabled),
<Button startIcon={<DownloadDone />}
variant={"outlined"} onClick={onTestRedis}
disabled={!settings.rate_limiting_enabled || !api.hasPermission("testRedis") || hasChanged}
title={hasChanged ? L("general.unsaved_changes") : ""}>
{L("settings.redis_test")}
</Button>
];
} else if (selectedTab === "uncategorized") {
return <TableContainer component={Paper}>
@ -357,57 +375,42 @@ export default function SettingsView(props) {
return <CircularProgress />
}
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"}>{L("settings.title")}</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">{L("settings.title")}</li>
</ol>
</div>
</div>
</div>
</div>
<div className={"content"}>
<Tabs value={selectedTab} onChange={(e, v) => setSelectedTab(v)} component={Paper}>
<Tab value={"general"} label={L("settings.general")}
icon={<SettingsApplications />} iconPosition={"start"} />
<Tab value={"mail"} label={L("settings.mail")}
icon={<Mail />} iconPosition={"start"} />
<Tab value={"captcha"} label={L("settings.captcha")}
icon={<SmartToy />} iconPosition={"start"} />
<Tab value={"redis"} label={L("settings.rate_limit")}
icon={<Storage />} iconPosition={"start"} />
<Tab value={"uncategorized"} label={L("settings.uncategorized")}
icon={<LibraryBooks />} iconPosition={"start"} />
</Tabs>
<Box p={2}>
return <ViewContent title={L("settings.title")} path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<span key={"settings"}>{L("settings.title")}</span>,
]}>
<Tabs value={selectedTab} onChange={(e, v) => setSelectedTab(v)} component={Paper}>
<Tab value={"general"} label={L("settings.general")}
icon={<SettingsApplications />} iconPosition={"start"} />
<Tab value={"mail"} label={L("settings.mail")}
icon={<Mail />} iconPosition={"start"} />
<Tab value={"captcha"} label={L("settings.captcha")}
icon={<SmartToy />} iconPosition={"start"} />
<Tab value={"redis"} label={L("settings.rate_limit")}
icon={<Storage />} iconPosition={"start"} />
<Tab value={"uncategorized"} label={L("settings.uncategorized")}
icon={<LibraryBooks />} iconPosition={"start"} />
</Tabs>
<Box p={2}>
{
renderTab()
}
</Box>
<ButtonBar>
<Button color={"primary"}
onClick={onSaveSettings}
disabled={isSaving || !api.hasPermission("settings/set")}
startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
variant={"outlined"} title={L(hasChanged ? "general.unsaved_changes" : "general.save")}>
{isSaving ? L("general.saving") + "…" : (L("general.save") + (hasChanged ? " *" : ""))}
</Button>
<Button color={"secondary"}
onClick={onReset}
disabled={isSaving}
startIcon={<RestartAlt />}
variant={"outlined"} title={L("general.reset")}>
{L("general.reset")}
</Button>
</ButtonBar>
</div>
</>
</Box>
<ButtonBar>
<Button color={"primary"}
onClick={onSaveSettings}
disabled={isSaving || !api.hasPermission("settings/set")}
startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
variant={"outlined"} title={L(hasChanged ? "general.unsaved_changes" : "general.save")}>
{isSaving ? L("general.saving") + "…" : (L("general.save") + (hasChanged ? " *" : ""))}
</Button>
<Button color={"secondary"}
onClick={onReset}
disabled={isSaving}
startIcon={<RestartAlt />}
variant={"outlined"} title={L("general.reset")}>
{L("general.reset")}
</Button>
</ButtonBar>
</ViewContent>
}

@ -3,7 +3,7 @@ import {useCallback, useContext, useEffect, useState} from "react";
import {CircularProgress} from "@mui/material";
import {LocaleContext} from "shared/locale";
import * as React from "react";
import ViewContent from "../../elements/view-content";
export default function UserEditView(props) {
@ -56,34 +56,11 @@ export default function UserEditView(props) {
return <CircularProgress />
}
return <div className={"content-header"}>
<div className={"container-fluid"}>
<ol className={"breadcrumb"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active"><Link to={"/admin/users"}>User</Link></li>
<li className="breadcrumb-item active">{ isNewUser ? "New" : "Edit" }</li>
</ol>
</div>
<div className={"content"}>
<div className={"container-fluid"}>
<h3>{L(isNewUser ? "Create new User" : "Edit User")}</h3>
<div className={"col-sm-12 col-lg-6"}>
<div className={"row"}>
<div className={"col-sm-6 form-group"}>
<label htmlFor={"username"}>{L("account.username")}</label>
<input type={"text"} className={"form-control"} placeholder={L("account.username")}
name={"username"} id={"username"} maxLength={32} value={user.name}
onChange={(e) => setUser({...user, name: e.target.value})}/>
</div>
<div className={"col-sm-6 form-group"}>
<label htmlFor={"fullName"}>{L("account.full_name")}</label>
<input type={"text"} className={"form-control"} placeholder={L("account.full_name")}
name={"fullName"} id={"fullName"} maxLength={32} value={user.fullName}
onChange={(e) => setUser({...user, fullName: e.target.value})}/>
</div>
</div>
</div>
</div>
</div>
</div>
return <ViewContent title={L(isNewUser ? "account.new_user" : "account.edit_user")} path={[
<Link key={"dashboard"} to={"/admin/dashboard"}>Home</Link>,
<Link key={"users"} to={"/admin/users"}>User</Link>,
<span key={"action"}>{isNewUser ? "New" : "Edit"}</span>
]}>
</ViewContent>
}

@ -12,7 +12,7 @@ import {
import {Chip} from "@mui/material";
import {Edit, Add} from "@mui/icons-material";
import usePagination from "shared/hooks/pagination";
import ViewContent from "../../elements/view-content";
export default function UserListView(props) {
@ -69,43 +69,25 @@ export default function UserListView(props) {
]),
];
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"}>{L("account.users")}</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">{L("account.users")}</li>
</ol>
</div>
</div>
</div>
</div>
<div className={"content"}>
<div className={"container-fluid"}>
<DataTable
data={users}
pagination={pagination}
defaultSortOrder={"asc"}
defaultSortColumn={0}
className={"table table-striped"}
fetchData={onFetchUsers}
placeholder={"No users to display"}
columns={columnDefinitions}
buttons={[{
key: "btn-create",
color: "primary",
startIcon: <Add />,
children: L("general.create_new"),
disabled: !api.hasPermission("user/create") && !api.hasPermission("user/invite"),
onClick: () => navigate("/admin/user/new")
}]}/>
</div>
</div>
</>
return <ViewContent title={L("account.users")} path={[
<Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<span key={"users"}>{L("account.users")}</span>
]}>
<DataTable
data={users}
pagination={pagination}
defaultSortOrder={"asc"}
defaultSortColumn={0}
fetchData={onFetchUsers}
placeholder={L("account.user_list_placeholder")}
columns={columnDefinitions}
buttons={[{
key: "btn-create",
color: "primary",
startIcon: <Add />,
children: L("general.create_new"),
disabled: !api.hasPermission("user/create") && !api.hasPermission("user/invite"),
onClick: () => navigate("/admin/user/new")
}]}/>
</ViewContent>
}

@ -22,8 +22,8 @@
"@babel/core": "^7.20.5",
"@babel/plugin-transform-react-jsx": "^7.19.0",
"@eslint/js": "^9.0.0",
"eslint-plugin-react": "^7.34.1",
"customize-cra": "^1.0.0",
"eslint-plugin-react": "^7.34.1",
"parcel": "^2.8.0",
"react-app-rewired": "^2.2.1",
"react-scripts": "^5.0.1"
@ -36,6 +36,7 @@
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@mui/base": "^5.0.0-beta.40",
"@mui/icons-material": "^5.11.0",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.14",

@ -425,4 +425,9 @@ export default class API {
since: since, severity: severity, query: query
});
}
/** Redis **/
async testRedis() {
return this.apiCall("testRedis");
}
};

@ -6,6 +6,7 @@ import {Box, Button, Select, TextField, Table, TableBody, TableCell, TableHead,
import {formatDate, formatDateTime} from "../util";
import {isNumber} from "chart.js/helpers";
import {ArrowUpward, ArrowDownward, Refresh} from "@mui/icons-material";
import TableBodyStriped from "./table-body-striped";
export function DataTable(props) {
@ -135,9 +136,9 @@ export function DataTable(props) {
{ headerRow }
</TableRow>
</TableHead>
<TableBody>
<TableBodyStriped>
{ rows }
</TableBody>
</TableBodyStriped>
</Table>
{pagination && pagination.renderPagination(L, numRows)}
</Box>

@ -1,24 +0,0 @@
import React from 'react';
export default function Icon(props) {
let classes = props.className || [];
classes = Array.isArray(classes) ? classes : classes.toString().split(" ");
let type = props.type || "fas";
let icon = props.icon;
classes.push(type);
classes.push("fa-" + icon);
if (icon === "spinner" || icon === "circle-notch") {
classes.push("fa-spin");
}
let newProps = {...props, className: classes.join(" ") };
delete newProps["type"];
delete newProps["icon"];
return (
<i {...newProps} />
);
}

@ -0,0 +1,12 @@
import {styled, TableBody} from "@mui/material";
const TableBodyStriped = styled(TableBody)(({ theme }) => ({
'& tr:nth-of-type(odd)': {
backgroundColor: theme.palette.grey[0],
},
'& tr:nth-of-type(even)': {
backgroundColor: theme.palette.grey[100],
},
}));
export default TableBodyStriped;