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", "active" => "Aktiv",
"group" => "Gruppe", "group" => "Gruppe",
"no_members" => "Keine Mitglieder in dieser Gruppe", "no_members" => "Keine Mitglieder in dieser Gruppe",
"user_list_placeholder" => "Keine Benutzer zum Anzeigen",
# profile picture # profile picture
"change_picture" => "Profilbild ändern", "change_picture" => "Profilbild ändern",

@ -83,4 +83,8 @@ return [
"datefns_time_format_precise" => "HH:mm:ss", "datefns_time_format_precise" => "HH:mm:ss",
"datefns_datetime_format" => "dd.MM.yyyy HH:mm", "datefns_datetime_format" => "dd.MM.yyyy HH:mm",
"datefns_datetime_format_precise" => "dd.MM.yyyy HH:mm:ss", "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_host" => "Redis Host",
"redis_port" => "Redis Port", "redis_port" => "Redis Port",
"redis_password" => "Redis Passwort", "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 # dialog
"fetch_settings_error" => "Fehler beim Holen der Einstellungen", "fetch_settings_error" => "Fehler beim Holen der Einstellungen",

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

@ -83,4 +83,8 @@ return [
"datefns_time_format_precise" => "pp", "datefns_time_format_precise" => "pp",
"datefns_datetime_format" => "MM/dd/yyyy p", "datefns_datetime_format" => "MM/dd/yyyy p",
"datefns_datetime_format_precise" => "MM/dd/yyyy pp", "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_host" => "Redis host",
"redis_port" => "Redis port", "redis_port" => "Redis port",
"redis_password" => "Redis password", "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 # dialog
"fetch_settings_error" => "Error fetching settings", "fetch_settings_error" => "Error fetching settings",

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

@ -51,6 +51,10 @@ The compiled dist files will be automatically moved to `/js`.
To spawn a temporary development server, run: To spawn a temporary development server, run:
```bash ```bash
php cli frontend dev <module>
```
or directly via yarn:
```bash
cd react cd react
yarn workspace $project run dev 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. 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. 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 ### Access Control
@ -355,7 +363,7 @@ php cli.php api <add> # interactive wizard
│ ├── shared # shared source files, including API and localization │ ├── shared # shared source files, including API and localization
│ ├── admin-panel # the admin panel source files │ ├── admin-panel # the admin panel source files
│ ├── dist # compiler output │ ├── 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 ├── docker # docker configuration files and build scripts
└── test # php unit & integraton test files └── 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> <!DOCTYPE html>
<html> <html>
<head>
<link rel="stylesheet" href="/css/fontawesome.min.css">
</head>
<body> <body>
<div id="admin-panel"></div> <div id="admin-panel"></div>
</body> </body>

@ -6,9 +6,6 @@ import Footer from "./elements/footer";
import {useContext, useEffect} from "react"; import {useContext, useEffect} from "react";
import {LocaleContext} from "shared/locale"; import {LocaleContext} from "shared/locale";
// css
import './res/adminlte.min.css';
// views // views
import View404 from "./views/404"; import View404 from "./views/404";
import clsx from "clsx"; import clsx from "clsx";
@ -61,16 +58,8 @@ export default function AdminDashboard(props) {
hideDialog: hideDialog 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> return <BrowserRouter>
<Sidebar {...controlObj} /> <Sidebar {...controlObj}>
<div className={"wrapper"}>
<div className={"content-wrapper p-2"}>
<section className={"content"}>
<Suspense fallback={<div>{L("general.loading")}... </div>}> <Suspense fallback={<div>{L("general.loading")}... </div>}>
<Routes> <Routes>
<Route path={"/admin"} element={<Navigate to={"/admin/dashboard"} />}/> <Route path={"/admin"} element={<Navigate to={"/admin/dashboard"} />}/>
@ -88,10 +77,8 @@ export default function AdminDashboard(props) {
<Route path={"*"} element={<View404 />} /> <Route path={"*"} element={<View404 />} />
</Routes> </Routes>
</Suspense> </Suspense>
<Dialog {...dialog}/>
</section>
</div>
</div>
<Footer info={info} /> <Footer info={info} />
</Sidebar>
<Dialog {...dialog}/>
</BrowserRouter> </BrowserRouter>
} }

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

@ -1,11 +1,20 @@
import React from "react"; 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) { export default function Footer(props) {
return ( return <StyledFooter>
<footer className={"main-footer"}> <Divider />
Theme: <strong>Copyright © 2014-2021 <a href={"https://adminlte.io"}>AdminLTE.io</a>. <b>Version</b> 3.2.0</strong>&nbsp; <b>Framework</b>: <a href={"https://git.romanh.de/Projekte/web-base"} target={"_blank"}>WebBase</a>&nbsp;Version {props.info.version}
Framework: <strong><a href={"https://git.romanh.de/Projekte/web-base"}>WebBase</a></strong>. <b>Version</b> {props.info.version} </StyledFooter>
</footer>
)
} }

@ -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 React, {useCallback, useContext, useEffect, useState} from 'react';
import {Link, NavLink} from "react-router-dom"; import {Link, useNavigate} from "react-router-dom";
import Icon from "shared/elements/icon";
import {LocaleContext} from "shared/locale"; 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 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) => ({ const ProfileLink = styled(Link)((props) => ({
"& > div": { "& > 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, width: 30,
height: 30, height: 30,
}, },
marginLeft: props.theme.spacing(1),
marginTop: props.theme.spacing(1),
display: "grid",
gridTemplateColumns: "45px auto",
"& > span": { "& > 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) { export default function Sidebar(props) {
const api = props.api; const api = props.api;
const showDialog = props.showDialog; 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(() => { const onLogout = useCallback(() => {
api.logout().then(obj => { api.logout().then(obj => {
@ -36,99 +113,156 @@ export default function Sidebar(props) {
}); });
}, [api, showDialog]); }, [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 = { const menuItems = {
"dashboard": { "dashboard": {
"name": "admin.dashboard", "name": "admin.dashboard",
"icon": "tachometer-alt" "icon": <QueryStats />
}, },
"users": { "users": {
"name": "admin.users", "name": "admin.users",
"icon": "users" "icon": <People />
}, },
"groups": { "groups": {
"name": "admin.groups", "name": "admin.groups",
"icon": "users-cog" "icon": <Groups />
}, },
"routes": { "routes": {
"name": "admin.page_routes", "name": "admin.page_routes",
"icon": "copy", "icon": <Route />
}, },
"settings": { "settings": {
"name": "admin.settings", "name": "admin.settings",
"icon": "tools" "icon": <Settings />
}, },
"permissions": { "permissions": {
"name": "admin.acl", "name": "admin.acl",
"icon": "door-open" "icon": <Security />
}, },
"logs": { "logs": {
"name": "admin.logs", "name": "admin.logs",
"icon": "file-medical-alt" "icon": <Dns />
},
"help": {
"name": "admin.help",
"icon": "question-circle"
}, },
}; };
let li = []; const NavbarItem = (props) => <ListItem disablePadding sx={{ display: 'block' }}>
for (let id in menuItems) { <ListItemButton onClick={props.onClick} selected={props.active} sx={{
let obj = menuItems[id]; minHeight: 48,
const badge = (obj.badge ? <span className={"right badge badge-" + obj.badge.type}>{obj.badge.value}</span> : <></>); 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>
li.push( let li = [];
<li key={id} className={"nav-item"}> for (const [id, menuItem] of Object.entries(menuItems)) {
<NavLink to={"/admin/" + id} className={"nav-link"}> const match= /^\/admin\/(.*)$/.exec(currentPath);
<Icon icon={obj.icon} className={"nav-icon"} /><p>{L(obj.name)}{badge}</p> const active = match?.length >= 2 && match[1] === id;
</NavLink> li.push(<NavbarItem key={id} {...menuItem} active={active} onClick={() => navigate(`/admin/${id}`)} />);
</li>
);
} }
li.push(<li className={"nav-item"} key={"logout"}> li.push(<NavbarItem key={"logout"} name={"general.logout"} icon={<ArrowBack />} onClick={onLogout}/>);
<a href={"#"} onClick={() => onLogout()} className={"nav-link"}>
<Icon icon={"arrow-left"} className={"nav-icon"} />
<p>{L("general.logout")}</p>
</a>
</li>);
return <> return <Box sx={{ display: 'flex' }}>
<aside className={"main-sidebar sidebar-dark-primary elevation-4"}> <CssBaseline />
<Link href={"#"} className={"brand-link"} to={"/admin/dashboard"}> <StyledDrawer variant={"permanent"} open={drawerOpen}>
<img src={"/img/icons/logo.png"} alt={"Logo"} className={"brand-image img-circle elevation-3"} style={{opacity: ".8"}} /> <DrawerHeader>
<span className={"brand-text font-weight-light ml-2"}>WebBase</span> {drawerOpen && <>
</Link> <img src={"/img/icons/logo.png"} alt={"Logo"} />
<span>WebBase</span>
<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 */} <IconButton onClick={() => setDrawerOpen(!drawerOpen)}>
<div className={"os-resize-observer-host"}> {drawerOpen ? <ChevronLeftIcon/> : <ChevronRightIcon/>}
<div className={"os-resize-observer observed"} style={{left: "0px", right: "auto"}}/> </IconButton>
</div> </DrawerHeader>
<div className={"os-size-auto-observer"} style={{height: "calc(100% + 1px)", float: "left"}}> <Divider/>
<div className={"os-resize-observer observed"}/> <ListItem sx={{display: 'block'}}>
</div> <Box sx={{opacity: drawerOpen ? 1 : 0}}>{L("account.logged_in_as")}:</Box>
<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"}> <ProfileLink to={"/admin/profile"}>
<ProfilePicture user={api.user}/> <ProfilePicture user={api.user}/>
<span>{api.user?.name || L("account.not_logged_in")}</span> {drawerOpen && <span>{api.user?.name || L("account.not_logged_in")}</span>}
</ProfileLink> </ProfileLink>
</div> </ListItem>
</div> <Divider/>
<nav className={"mt-2"}> <List>
<ul className={"nav nav-pills nav-sidebar flex-column"} data-widget={"treeview"} role={"menu"} data-accordion={"false"}>
{li} {li}
</ul> </List>
</nav> <Divider/>
</div> <ListItem sx={{display: 'block'}}>
</div> { drawerOpen ?
</div> <Select native value={currentLocale} size={"small"} fullWidth={true}
</div> onChange={e => onSetLanguage(e.target.value)}>
</aside> {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, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
IconButton, styled, FormGroup, FormLabel, Box IconButton, styled, FormGroup, FormLabel, Box, Grid
} from "@mui/material"; } from "@mui/material";
import {Add, Delete, Edit, Refresh} from "@mui/icons-material"; import {Add, Delete, Edit, Refresh} from "@mui/icons-material";
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 ViewContent from "../elements/view-content";
import ButtonBar from "../elements/button-bar";
import TableBodyStriped from "shared/elements/table-body-striped";
const BorderedColumn = styled(TableCell)({ const BorderedColumn = styled(TableCell)({
borderLeft: "1px dotted #666", borderLeft: "1px dotted #666",
@ -206,24 +209,13 @@ export default function AccessControlList(props) {
return <>{rows}</> return <>{rows}</>
} }
return <> return <ViewContent title={L("permissions.title")} path={[
<div className={"content-header"}> <Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<div className={"container-fluid"}> <span key={"permissions"}>{L("permissions.title_short")}</span>,
<div className={"row mb-2"}> ]}>
<div className={"col-sm-6"}> <Grid container>
<h1 className={"m-0 text-dark"}>{L("permissions.title")}</h1> <Grid item xs={6}>
</div> <FormGroup>
<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> <FormLabel>{L("permissions.search")}</FormLabel>
<TextField <TextField
placeholder={L("permissions.query") + "…"} placeholder={L("permissions.query") + "…"}
@ -232,12 +224,13 @@ export default function AccessControlList(props) {
variant={"outlined"} variant={"outlined"}
size={"small"}/> size={"small"}/>
</FormGroup> </FormGroup>
<div className={"col-6 text-right"}> </Grid>
<Grid item xs={6} textAlign={"end"}>
<Box> <Box>
<FormLabel>{L("general.controls")}</FormLabel> <FormLabel>{L("general.controls")}</FormLabel>
</Box> </Box>
<Box mb={2}> <ButtonBar mb={2}>
<Button variant={"outlined"} color={"primary"} className={"mr-1"} size={"small"} <Button variant={"outlined"} color={"primary"} size={"small"}
startIcon={<Refresh/>} onClick={() => onFetchACL(true)}> startIcon={<Refresh/>} onClick={() => onFetchACL(true)}>
{L("general.reload")} {L("general.reload")}
</Button> </Button>
@ -250,17 +243,22 @@ export default function AccessControlList(props) {
{type: "label", value: L("permissions.method") + ":"}, {type: "label", value: L("permissions.method") + ":"},
{type: "text", name: "method", value: "", placeholder: L("permissions.method")}, {type: "text", name: "method", value: "", placeholder: L("permissions.method")},
{type: "label", value: L("permissions.description") + ":"}, {type: "label", value: L("permissions.description") + ":"},
{ type: "text", name: "description", maxLength: 128, placeholder: L("permissions.description") } {
type: "text",
name: "description",
maxLength: 128,
placeholder: L("permissions.description")
}
], ],
onOption: (option, inputData) => option === 0 ? onUpdatePermission(inputData, []) : true onOption: (option, inputData) => option === 0 ? onUpdatePermission(inputData, []) : true
})}> })}>
{L("general.add")} {L("general.add")}
</Button> </Button>
</Box> </ButtonBar>
</div> </Grid>
</div> </Grid>
<TableContainer component={Paper} style={{overflowX: "initial"}}> <TableContainer component={Paper} style={{overflowX: "initial"}}>
<Table stickyHeader size={"small"} className={"table-striped"}> <Table stickyHeader size={"small"}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>{L("permissions.permission")}</TableCell> <TableCell>{L("permissions.permission")}</TableCell>
@ -270,9 +268,9 @@ export default function AccessControlList(props) {
</TableCell>)} </TableCell>)}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBodyStriped>
<PermissionList/> <PermissionList/>
</TableBody> </TableBodyStriped>
</Table> </Table>
</TableContainer> </TableContainer>
<Dialog show={dialogData.open} <Dialog show={dialogData.open}
@ -282,5 +280,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")]}/>
</> </ViewContent>
} }

@ -8,10 +8,12 @@ import {DataTable, ControlsColumn, NumericColumn, StringColumn} from "shared/ele
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import usePagination from "shared/hooks/pagination"; import usePagination from "shared/hooks/pagination";
import Dialog from "shared/elements/dialog"; 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 {Add, Delete, KeyboardArrowLeft, Save} from "@mui/icons-material";
import {MuiColorInput} from "mui-color-input"; import {MuiColorInput} from "mui-color-input";
import ButtonBar from "../../elements/button-bar"; import ButtonBar from "../../elements/button-bar";
import ViewContent from "../../elements/view-content";
import FormGroup from "../../elements/form-group";
const defaultGroupData = { const defaultGroupData = {
name: "", name: "",
@ -175,28 +177,15 @@ export default function EditGroupView(props) {
} }
return <> return <>
<div className={"content-header"}> <ViewContent title={ isNewGroup ? L("account.new_group") : L("account.group") + ": " + group.name }
<div className={"container-fluid"}> path={[
<div className={"row mb-2"}> <Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<div className={"col-sm-6"}> <Link key={"group"} to={"/admin/groups"}>{L("account.group")}</Link>,
<h1 className={"m-0 text-dark"}> <span key={"action"} >{isNewGroup ? L("general.new") : groupId}</span>,
{ isNewGroup ? L("account.new_group") : L("account.group") + ": " + group.name } ]}>
</h1> <Grid container>
</div> <Grid item xs={6}>
<div className={"col-sm-6"}> <FormGroup>
<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"}> <FormLabel htmlFor={"name"}>
{L("account.group_name")} {L("account.group_name")}
</FormLabel> </FormLabel>
@ -208,7 +197,7 @@ export default function EditGroupView(props) {
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup className={"my-2"}> <FormGroup>
<FormLabel htmlFor={"color"}> <FormLabel htmlFor={"color"}>
{L("account.color")} {L("account.color")}
</FormLabel> </FormLabel>
@ -249,17 +238,14 @@ export default function EditGroupView(props) {
</Button> </Button>
} }
</ButtonBar> </ButtonBar>
</div>
</div>
{!isNewGroup && api.hasPermission("groups/getMembers") ? {!isNewGroup && api.hasPermission("groups/getMembers") ?
<Box m={3} className={"col-6"}> <Box mt={3}>
<h4>{L("account.members")}</h4> <h4>{L("account.members")}</h4>
<DataTable <DataTable
data={members} data={members}
pagination={pagination} pagination={pagination}
defaultSortOrder={"asc"} defaultSortOrder={"asc"}
defaultSortColumn={0} defaultSortColumn={0}
className={"table table-striped"}
fetchData={onFetchMembers} fetchData={onFetchMembers}
placeholder={L("account.no_members")} placeholder={L("account.no_members")}
columns={[ columns={[
@ -298,7 +284,9 @@ export default function EditGroupView(props) {
</Box> </Box>
: <></> : <></>
} }
</div> </Grid>
</Grid>
</ViewContent>
<Dialog show={dialogData.open} <Dialog show={dialogData.open}
onClose={() => setDialogData({open: false})} onClose={() => setDialogData({open: false})}
title={dialogData.title} title={dialogData.title}
@ -307,5 +295,4 @@ export default function EditGroupView(props) {
inputs={dialogData.inputs} inputs={dialogData.inputs}
options={[L("general.ok"), L("general.cancel")]} /> 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 {ControlsColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
import {Add, Edit} from "@mui/icons-material"; import {Add, Edit} from "@mui/icons-material";
import usePagination from "shared/hooks/pagination"; import usePagination from "shared/hooks/pagination";
import ViewContent from "../../elements/view-content";
export default function GroupListView(props) { export default function GroupListView(props) {
@ -46,29 +47,15 @@ export default function GroupListView(props) {
]), ]),
]; ];
return <> return <ViewContent title={L("account.groups")} path={[
<div className={"content-header"}> <Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<div className={"container-fluid"}> <span key={"groups"} >{L("account.groups")}</span>,
<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 <DataTable
data={groups} data={groups}
pagination={pagination} pagination={pagination}
defaultSortOrder={"asc"} defaultSortOrder={"asc"}
defaultSortColumn={0} defaultSortColumn={0}
className={"table table-striped"}
fetchData={onFetchGroups} fetchData={onFetchGroups}
placeholder={"No groups to display"} placeholder={"No groups to display"}
columns={columnDefinitions} columns={columnDefinitions}
@ -80,8 +67,5 @@ export default function GroupListView(props) {
disabled: !api.hasPermission("groups/create"), disabled: !api.hasPermission("groups/create"),
children: L("general.create_new") children: L("general.create_new")
}]}/> }]}/>
</div> </ViewContent>
</div>
</div>
</>
} }

@ -3,13 +3,14 @@ import {LocaleContext} from "shared/locale";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import usePagination from "shared/hooks/pagination"; import usePagination from "shared/hooks/pagination";
import {DataColumn, DataTable, DateTimeColumn, NumericColumn, StringColumn} from "shared/elements/data-table"; 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 {DateTimePicker} from "@mui/x-date-pickers";
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import {API_DATETIME_FORMAT} from "shared/constants"; import {API_DATETIME_FORMAT} from "shared/constants";
import {format, toDate} from "date-fns"; import {format, toDate} from "date-fns";
import {ExpandLess, ExpandMore} from "@mui/icons-material"; import {ExpandLess, ExpandMore} from "@mui/icons-material";
import ViewContent from "../elements/view-content";
export default function LogView(props) { export default function LogView(props) {
@ -106,24 +107,13 @@ export default function LogView(props) {
messageColumn, messageColumn,
]; ];
return <> return <ViewContent title={L("logs.title")} path={[
<div className={"content-header"}> <Link key={"dashboard"} to={"/admin/dashboard"}>Home</Link>,
<div className={"container-fluid"}> <span key={"logs"}>{L("logs.title")}</span>
<div className={"row mb-2"}> ]}>
<div className={"col-sm-6"}> <Grid container spacing={2}>
<h1 className={"m-0 text-dark"}>{L("logs.title")}</h1> <Grid item xs={2}>
</div> <FormGroup>
<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> <FormLabel>{L("logs.severity")}</FormLabel>
<FormControl> <FormControl>
<TextField select variant={"outlined"} size={"small"} value={logLevel} <TextField select variant={"outlined"} size={"small"} value={logLevel}
@ -135,7 +125,9 @@ export default function LogView(props) {
</TextField> </TextField>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup className={"col-4"}> </Grid>
<Grid item xs={4}>
<FormGroup>
<FormLabel>{L("logs.timestamp")}</FormLabel> <FormLabel>{L("logs.timestamp")}</FormLabel>
<FormControl> <FormControl>
<LocalizationProvider dateAdapter={AdapterDateFns}> <LocalizationProvider dateAdapter={AdapterDateFns}>
@ -149,7 +141,9 @@ export default function LogView(props) {
</LocalizationProvider> </LocalizationProvider>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup className={"col-6"}> </Grid>
<Grid item xs={6}>
<FormGroup>
<FormLabel>{L("logs.search")}</FormLabel> <FormLabel>{L("logs.search")}</FormLabel>
<FormControl> <FormControl>
<TextField <TextField
@ -160,20 +154,16 @@ export default function LogView(props) {
size={"small"}/> size={"small"}/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
</div> </Grid>
<div className={"container-fluid"}> </Grid>
<DataTable <DataTable
data={logEntries} data={logEntries}
pagination={pagination} pagination={pagination}
className={"table table-striped"}
fetchData={onFetchLogs} fetchData={onFetchLogs}
forceReload={forceReload} forceReload={forceReload}
defaultSortColumn={3} defaultSortColumn={3}
defaultSortOrder={"desc"} defaultSortOrder={"desc"}
placeholder={L("logs.no_entries_placeholder")} placeholder={L("logs.no_entries_placeholder")}
columns={columnDefinitions}/> columns={columnDefinitions}/>
</div> </ViewContent>
</div>
</div>
</>
} }

@ -13,26 +13,65 @@ import {
LibraryBooks, LibraryBooks,
People People
} from "@mui/icons-material"; } 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"}> const StyledStatBox = styled(Alert)((props) => ({
<div className={"small-box bg-" + props.color}> position: "relative",
<div className={"inner"}> 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) ? {!isNaN(props.count) ?
<> <>
<h3>{props.count}</h3> <h2>{props.count}</h2>
<p>{props.text}</p> <p>{props.text}</p>
</> : <CircularProgress variant={"determinate"} /> </> : <CircularProgress variant={"determinate"} />
} }
</div> </Box>
<div className={"icon"}> <Box>{props.icon}</Box>
{props.icon} <Box>
</div> <Link to={props.link}>
<Link to={props.link} className={"small-box-footer text-right p-1"}>
More info <ArrowCircleRight /> More info <ArrowCircleRight />
</Link> </Link>
</div> </Box>
</div> </StyledStatBox>
const StatusLine = (props) => { const StatusLine = (props) => {
const {enabled, text, ...other} = props; const {enabled, text, ...other} = props;
@ -84,45 +123,38 @@ export default function Overview(props) {
loadAvg = loadAvg.map(v => sprintf("%.1f", v)).join(", "); loadAvg = loadAvg.map(v => sprintf("%.1f", v)).join(", ");
} }
return <> return <ViewContent title={L("admin.dashboard")} path={[
<div className={"content-header"}> <Link key={"home"} to={"/admin/dashboard"}>Home</Link>
<div className={"container-fluid"}> ]}>
<div className={"row mb-2"}> <Grid container spacing={2}>
<div className={"col-sm-6"}> <Grid item xs={3}>
<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} <StatBox color={"info"} count={stats?.userCount}
text={L("admin.users_registered")} text={L("admin.users_registered")}
icon={<People/>} icon={<People/>}
link={"/admin/users"}/> link={"/admin/users"}/>
</Grid>
<Grid item xs={3}>
<StatBox color={"success"} count={stats?.groupCount} <StatBox color={"success"} count={stats?.groupCount}
text={L("admin.available_groups")} text={L("admin.available_groups")}
icon={<Groups/>} icon={<Groups/>}
link={"/admin/groups"}/> link={"/admin/groups"}/>
</Grid>
<Grid item xs={3}>
<StatBox color={"warning"} count={stats?.pageCount} <StatBox color={"warning"} count={stats?.pageCount}
text={L("admin.routes_defined")} text={L("admin.routes_defined")}
icon={<LibraryBooks/>} icon={<LibraryBooks/>}
link={"/admin/routes"}/> link={"/admin/routes"}/>
<StatBox color={"danger"} count={stats?.errorCount} </Grid>
<Grid item xs={3}>
<StatBox color={"error"} count={stats?.errorCount}
text={L("admin.error_count")} text={L("admin.error_count")}
icon={<BugReport />} icon={<BugReport />}
link={"/admin/logs"}/> link={"/admin/logs"}/>
</div> </Grid>
</div> </Grid>
<Box m={2} p={2} component={Paper}> <Box m={2} p={2} component={Paper}>
<h4>Server Stats</h4><hr /> <h4>Server Stats</h4>
<Divider />
{stats === null ? <CircularProgress/> : {stats === null ? <CircularProgress/> :
<Table> <Table>
<TableBody> <TableBody>
@ -174,6 +206,5 @@ export default function Overview(props) {
</Table> </Table>
} }
</Box> </Box>
</section> </ViewContent>
</>
} }

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

@ -18,6 +18,7 @@ import ChangePasswordBox from "./change-password-box";
import GpgBox from "./gpg-box"; import GpgBox from "./gpg-box";
import MultiFactorBox from "./mfa-box"; import MultiFactorBox from "./mfa-box";
import EditProfilePicture from "./edit-picture"; import EditProfilePicture from "./edit-picture";
import ViewContent from "../../elements/view-content";
export default function ProfileView(props) { export default function ProfileView(props) {
@ -78,24 +79,10 @@ export default function ProfileView(props) {
}, [profile, changePassword, api, showDialog, isSaving]); }, [profile, changePassword, api, showDialog, isSaving]);
return <> return <>
<div className={"content-header"}> <ViewContent title={L("account.edit_profile")} path={[
<div className={"container-fluid"}> <Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<div className={"row mb-2"}> <span key={"profile"}>{L("account.profile")}</span>
<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>
<Box display={"grid"} gridTemplateColumns={"300px auto"}> <Box display={"grid"} gridTemplateColumns={"300px auto"}>
<EditProfilePicture api={api} showDialog={showDialog} setProfile={setProfile} <EditProfilePicture api={api} showDialog={showDialog} setProfile={setProfile}
profile={profile} setDialogData={setDialogData} /> profile={profile} setDialogData={setDialogData} />
@ -154,8 +141,7 @@ export default function ProfileView(props) {
{isSaving ? L("general.saving") + "…" : L("general.save")} {isSaving ? L("general.saving") + "…" : L("general.save")}
</Button> </Button>
</Box> </Box>
</Box> </ViewContent>
<Dialog show={dialogData.show} <Dialog show={dialogData.show}
title={dialogData.title} title={dialogData.title}
message={dialogData.message} message={dialogData.message}

@ -11,6 +11,7 @@ import * as React from "react";
import RouteForm from "./route-form"; import RouteForm from "./route-form";
import {KeyboardArrowLeft, Save} from "@mui/icons-material"; import {KeyboardArrowLeft, Save} from "@mui/icons-material";
import ButtonBar from "../../elements/button-bar"; import ButtonBar from "../../elements/button-bar";
import ViewContent from "../../elements/view-content";
const MonoSpaceTextField = styled(TextField)((props) => ({ const MonoSpaceTextField = styled(TextField)((props) => ({
"& input": { "& input": {
@ -113,19 +114,12 @@ export default function RouteEditView(props) {
return <CircularProgress/> return <CircularProgress/>
} }
return <div className={"content-header"}> return <ViewContent title={L(isNewRoute ? "routes.create_route_title" : "routes.edit_route_title")}
<div className={"container-fluid"}> path={[
<ol className={"breadcrumb"}> <Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> <Link key={"routes"} to={"/admin/routes"}>{L("routes.title")}</Link>,
<li className="breadcrumb-item active"><Link to={"/admin/routes"}>{L("routes.title")}</Link></li> <span key={"action"}>{isNewRoute ? L("general.new") : L("general.edit")}</span>,
<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} /> <RouteForm route={route} setRoute={setRoute} />
<ButtonBar mt={2}> <ButtonBar mt={2}>
<Button startIcon={<KeyboardArrowLeft />} <Button startIcon={<KeyboardArrowLeft />}
@ -150,5 +144,5 @@ export default function RouteEditView(props) {
Match: {JSON.stringify(routeTestResult)} Match: {JSON.stringify(routeTestResult)}
</pre> </pre>
</Box> </Box>
</div> </ViewContent>
} }

@ -9,12 +9,15 @@ import {
TableHead, TableHead,
TableRow, TableRow,
Button, Button,
IconButton, Checkbox IconButton, Checkbox, Box
} from "@mui/material"; } from "@mui/material";
import {useCallback, useContext, useEffect, useState} from "react"; import {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale"; import {LocaleContext} from "shared/locale";
import {Add, Cached, Delete, Edit, Refresh} from "@mui/icons-material"; import {Add, Cached, Delete, Edit, Refresh} from "@mui/icons-material";
import Dialog from "shared/elements/dialog"; 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) => ({ const RouteTableRow = styled(TableRow)((props) => ({
"& td": { "& td": {
@ -22,7 +25,6 @@ const RouteTableRow = styled(TableRow)((props) => ({
} }
})); }));
export default function RouteListView(props) { export default function RouteListView(props) {
// meta // meta
@ -118,44 +120,28 @@ export default function RouteListView(props) {
const BoolCell = (props) => props.checked ? L("general.yes") : L("general.no") const BoolCell = (props) => props.checked ? L("general.yes") : L("general.no")
return <> return <>
<div className={"content-header"}> <ViewContent title={L("routes.title")} path={[
<div className={"container-fluid"}> <Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<div className={"row mb-2"}> <span key={"routes"}>{L("routes.title")}</span>
<div className={"col-sm-6"}> ]}>
<h1 className={"m-0 text-dark"}>{L("routes.title")}</h1> <ButtonBar mb={1}>
</div> <Button variant={"outlined"} color={"primary"} size={"small"}
<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)}> startIcon={<Refresh />} onClick={() => onFetchRoutes(true)}>
{L("general.reload")} {L("general.reload")}
</Button> </Button>
<Button variant={"outlined"} className={"m-1"} startIcon={<Add />} size={"small"} <Button variant={"outlined"} startIcon={<Add />} size={"small"}
disabled={!props.api.hasPermission("routes/add")} disabled={!props.api.hasPermission("routes/add")}
onClick={() => navigate("/admin/routes/new")} > onClick={() => navigate("/admin/routes/new")} >
{L("general.add")} {L("general.add")}
</Button> </Button>
<Button variant={"outlined"} className={"m-1"} startIcon={<Cached />} size={"small"} <Button variant={"outlined"} startIcon={<Cached />} size={"small"}
disabled={!props.api.hasPermission("routes/generateCache") || isGeneratingCache} disabled={!props.api.hasPermission("routes/generateCache") || isGeneratingCache}
onClick={onRegenerateCache} > onClick={onRegenerateCache} >
{isGeneratingCache ? L("routes.regenerating_cache") + "…" : L("routes.regenerate_cache")} {isGeneratingCache ? L("routes.regenerating_cache") + "…" : L("routes.regenerate_cache")}
</Button> </Button>
</div> </ButtonBar>
</div> <TableContainer component={Paper} sx={{overflowX: "initial"}}>
</div> <Table stickyHeader size={"small"}>
<TableContainer component={Paper} style={{overflowX: "initial"}}>
<Table stickyHeader size={"small"} className={"table-striped"}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>{L("general.id")}</TableCell> <TableCell>{L("general.id")}</TableCell>
@ -168,7 +154,7 @@ export default function RouteListView(props) {
<TableCell align={"center"}>{L("general.controls")}</TableCell> <TableCell align={"center"}>{L("general.controls")}</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBodyStriped>
{Object.entries(routes).map(([id, route]) => {Object.entries(routes).map(([id, route]) =>
<RouteTableRow key={"route-" + id}> <RouteTableRow key={"route-" + id}>
<TableCell>{route.id}</TableCell> <TableCell>{route.id}</TableCell>
@ -208,11 +194,15 @@ export default function RouteListView(props) {
</TableCell> </TableCell>
</RouteTableRow> </RouteTableRow>
)} )}
</TableBody> </TableBodyStriped>
</Table> </Table>
</TableContainer> </TableContainer>
</ViewContent>
<Dialog show={dialogData.open} <Dialog show={dialogData.open}
onClose={() => { setDialogData({open: false}); dialogData.onClose && dialogData.onClose() }} onClose={() => {
setDialogData({open: false});
dialogData.onClose && dialogData.onClose()
}}
title={dialogData.title} title={dialogData.title}
message={dialogData.message} message={dialogData.message}
onOption={dialogData.onOption} onOption={dialogData.onOption}

@ -17,7 +17,7 @@ import {
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import { import {
Add, Add,
Delete, Delete, DownloadDone,
LibraryBooks, LibraryBooks,
Mail, Mail,
RestartAlt, RestartAlt,
@ -33,6 +33,7 @@ import SettingsNumberInput from "./input-number";
import SettingsPasswordInput from "./input-password"; import SettingsPasswordInput from "./input-password";
import SettingsTextInput from "./input-text"; import SettingsTextInput from "./input-text";
import SettingsSelection from "./input-selection"; import SettingsSelection from "./input-selection";
import ViewContent from "../../elements/view-content";
export default function SettingsView(props) { export default function SettingsView(props) {
@ -197,6 +198,16 @@ export default function SettingsView(props) {
} }
}, [api, showDialog, testMailAddress, isSending]); }, [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(() => { const onReset = useCallback(() => {
setFetchSettings(true); setFetchSettings(true);
setNewKey(""); setNewKey("");
@ -266,7 +277,8 @@ export default function SettingsView(props) {
<Button startIcon={isSending ? <CircularProgress size={14} /> : <Send />} <Button startIcon={isSending ? <CircularProgress size={14} /> : <Send />}
variant={"outlined"} onClick={onSendTestMail} variant={"outlined"} onClick={onSendTestMail}
fullWidth={true} 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")} {isSending ? L("general.sending") + "…" : L("general.send")}
</Button> </Button>
</Grid> </Grid>
@ -300,6 +312,12 @@ export default function SettingsView(props) {
renderTextInput("redis_host", !settings.rate_limiting_enabled), renderTextInput("redis_host", !settings.rate_limiting_enabled),
renderNumberInput("redis_port", 1, 65535, !settings.rate_limiting_enabled), renderNumberInput("redis_port", 1, 65535, !settings.rate_limiting_enabled),
renderPasswordInput("redis_password", !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") { } else if (selectedTab === "uncategorized") {
return <TableContainer component={Paper}> return <TableContainer component={Paper}>
@ -357,23 +375,10 @@ export default function SettingsView(props) {
return <CircularProgress /> return <CircularProgress />
} }
return <> return <ViewContent title={L("settings.title")} path={[
<div className={"content-header"}> <Link key={"home"} to={"/admin/dashboard"}>Home</Link>,
<div className={"container-fluid"}> <span key={"settings"}>{L("settings.title")}</span>,
<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}> <Tabs value={selectedTab} onChange={(e, v) => setSelectedTab(v)} component={Paper}>
<Tab value={"general"} label={L("settings.general")} <Tab value={"general"} label={L("settings.general")}
icon={<SettingsApplications />} iconPosition={"start"} /> icon={<SettingsApplications />} iconPosition={"start"} />
@ -407,7 +412,5 @@ export default function SettingsView(props) {
{L("general.reset")} {L("general.reset")}
</Button> </Button>
</ButtonBar> </ButtonBar>
</div> </ViewContent>
</>
} }

@ -3,7 +3,7 @@ import {useCallback, useContext, useEffect, useState} from "react";
import {CircularProgress} from "@mui/material"; import {CircularProgress} from "@mui/material";
import {LocaleContext} from "shared/locale"; import {LocaleContext} from "shared/locale";
import * as React from "react"; import * as React from "react";
import ViewContent from "../../elements/view-content";
export default function UserEditView(props) { export default function UserEditView(props) {
@ -56,34 +56,11 @@ export default function UserEditView(props) {
return <CircularProgress /> return <CircularProgress />
} }
return <div className={"content-header"}> return <ViewContent title={L(isNewUser ? "account.new_user" : "account.edit_user")} path={[
<div className={"container-fluid"}> <Link key={"dashboard"} to={"/admin/dashboard"}>Home</Link>,
<ol className={"breadcrumb"}> <Link key={"users"} to={"/admin/users"}>User</Link>,
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> <span key={"action"}>{isNewUser ? "New" : "Edit"}</span>
<li className="breadcrumb-item active"><Link to={"/admin/users"}>User</Link></li> ]}>
<li className="breadcrumb-item active">{ isNewUser ? "New" : "Edit" }</li>
</ol> </ViewContent>
</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>
} }

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

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

@ -425,4 +425,9 @@ export default class API {
since: since, severity: severity, query: query 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 {formatDate, formatDateTime} from "../util";
import {isNumber} from "chart.js/helpers"; import {isNumber} from "chart.js/helpers";
import {ArrowUpward, ArrowDownward, Refresh} from "@mui/icons-material"; import {ArrowUpward, ArrowDownward, Refresh} from "@mui/icons-material";
import TableBodyStriped from "./table-body-striped";
export function DataTable(props) { export function DataTable(props) {
@ -135,9 +136,9 @@ export function DataTable(props) {
{ headerRow } { headerRow }
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBodyStriped>
{ rows } { rows }
</TableBody> </TableBodyStriped>
</Table> </Table>
{pagination && pagination.renderPagination(L, numRows)} {pagination && pagination.renderPagination(L, numRows)}
</Box> </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;