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,37 +58,27 @@ 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"}> <Suspense fallback={<div>{L("general.loading")}... </div>}>
<div className={"content-wrapper p-2"}> <Routes>
<section className={"content"}> <Route path={"/admin"} element={<Navigate to={"/admin/dashboard"} />}/>
<Suspense fallback={<div>{L("general.loading")}... </div>}> <Route path={"/admin/dashboard"} element={<Overview {...controlObj} />}/>
<Routes> <Route path={"/admin/users"} element={<UserListView {...controlObj} />}/>
<Route path={"/admin"} element={<Navigate to={"/admin/dashboard"} />}/> <Route path={"/admin/user/:userId"} element={<UserEditView {...controlObj} />}/>
<Route path={"/admin/dashboard"} element={<Overview {...controlObj} />}/> <Route path={"/admin/groups"} element={<GroupListView {...controlObj} />}/>
<Route path={"/admin/users"} element={<UserListView {...controlObj} />}/> <Route path={"/admin/group/:groupId"} element={<EditGroupView {...controlObj} />}/>
<Route path={"/admin/user/:userId"} element={<UserEditView {...controlObj} />}/> <Route path={"/admin/logs"} element={<LogView {...controlObj} />}/>
<Route path={"/admin/groups"} element={<GroupListView {...controlObj} />}/> <Route path={"/admin/permissions"} element={<AccessControlList {...controlObj} />}/>
<Route path={"/admin/group/:groupId"} element={<EditGroupView {...controlObj} />}/> <Route path={"/admin/routes"} element={<RouteListView {...controlObj} />}/>
<Route path={"/admin/logs"} element={<LogView {...controlObj} />}/> <Route path={"/admin/routes/:routeId"} element={<RouteEditView {...controlObj} />}/>
<Route path={"/admin/permissions"} element={<AccessControlList {...controlObj} />}/> <Route path={"/admin/settings"} element={<SettingsView {...controlObj} />}/>
<Route path={"/admin/routes"} element={<RouteListView {...controlObj} />}/> <Route path={"/admin/profile"} element={<ProfileView {...controlObj} />}/>
<Route path={"/admin/routes/:routeId"} element={<RouteEditView {...controlObj} />}/> <Route path={"*"} element={<View404 />} />
<Route path={"/admin/settings"} element={<SettingsView {...controlObj} />}/> </Routes>
<Route path={"/admin/profile"} element={<ProfileView {...controlObj} />}/> </Suspense>
<Route path={"*"} element={<View404 />} /> <Footer info={info} />
</Routes> </Sidebar>
</Suspense> <Dialog {...dialog}/>
<Dialog {...dialog}/>
</section>
</div>
</div>
<Footer info={info} />
</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 = [];
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( const NavbarItem = (props) => <ListItem disablePadding sx={{ display: 'block' }}>
<li key={id} className={"nav-item"}> <ListItemButton onClick={props.onClick} selected={props.active} sx={{
<NavLink to={"/admin/" + id} className={"nav-link"}> minHeight: 48,
<Icon icon={obj.icon} className={"nav-icon"} /><p>{L(obj.name)}{badge}</p> justifyContent: drawerOpen ? 'initial' : 'center',
</NavLink> px: 2.5,
</li> }}>
); <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"}> 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"}}/> <ProfileLink to={"/admin/profile"}>
<div className={"os-padding"}> <ProfilePicture user={api.user}/>
<div className={"os-viewport os-viewport-native-scrollbars-invisible"} style={{right: "0px", bottom: "0px"}}> {drawerOpen && <span>{api.user?.name || L("account.not_logged_in")}</span>}
<div className={"os-content"} style={{padding: "0px 0px", height: "100%", width: "100%"}}> </ProfileLink>
<div className="user-panel mt-3 pb-3 mb-3 d-flex"> </ListItem>
<div className="info"> <Divider/>
<div className={"d-block text-light"}>{L("account.logged_in_as")}:</div> <List>
<ProfileLink to={"/admin/profile"}> {li}
<ProfilePicture user={api.user} /> </List>
<span>{api.user?.name || L("account.not_logged_in")}</span> <Divider/>
</ProfileLink> <ListItem sx={{display: 'block'}}>
</div> { drawerOpen ?
</div> <Select native value={currentLocale} size={"small"} fullWidth={true}
<nav className={"mt-2"}> onChange={e => onSetLanguage(e.target.value)}>
<ul className={"nav nav-pills nav-sidebar flex-column"} data-widget={"treeview"} role={"menu"} data-accordion={"false"}> {Object.values(languages || {}).map(language =>
{li} <option key={language.code} value={language.code}>
</ul> {language.name}
</nav> </option>)
</div> }
</div> </Select>
</div> : <ListItemButton sx={{
</div> minHeight: 48,
</aside> 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,73 +209,68 @@ 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"}> <FormLabel>{L("permissions.search")}</FormLabel>
<ol className={"breadcrumb float-sm-right"}> <TextField
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> placeholder={L("permissions.query") + "…"}
<li className="breadcrumb-item active">{L("permissions.title_short")}</li> value={query}
</ol> onChange={e => setQuery(e.target.value)}
</div> variant={"outlined"}
</div> size={"small"}/>
</div> </FormGroup>
</div> </Grid>
<div className={"row"}> <Grid item xs={6} textAlign={"end"}>
<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"}>
<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>
<Button variant={"outlined"} startIcon={<Add />} size={"small"} <Button variant={"outlined"} startIcon={<Add/>} size={"small"}
disabled={!props.api.hasGroup(USER_GROUP_ADMIN)} disabled={!props.api.hasGroup(USER_GROUP_ADMIN)}
onClick={() => setDialogData({ onClick={() => setDialogData({
open: true, open: true,
title: L("permissions.add_permission"), title: L("permissions.add_permission"),
inputs: [ inputs: [
{ 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>
<BorderedColumn align={"center"}><i>{L("permissions.everyone")}</i></BorderedColumn> <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} {group.name}
</TableCell>) } </TableCell>)}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBodyStriped>
<PermissionList /> <PermissionList/>
</TableBody> </TableBodyStriped>
</Table> </Table>
</TableContainer> </TableContainer>
<Dialog show={dialogData.open} <Dialog show={dialogData.open}
@ -281,6 +279,6 @@ export default function AccessControlList(props) {
message={dialogData.message} message={dialogData.message}
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,91 +177,75 @@ 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"}> <FormLabel htmlFor={"name"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> {L("account.group_name")}
<li className="breadcrumb-item active"><Link to={"/admin/groups"}>{L("account.group")}</Link></li> </FormLabel>
<li className="breadcrumb-item active">{ isNewGroup ? L("general.new") : groupId }</li> <FormControl>
</ol> <TextField maxLength={32} value={group.name}
</div> size={"small"} name={"name"}
</div> placeholder={L("account.name")}
</div> onChange={e => setGroup({...group, name: e.target.value})}/>
</div> </FormControl>
<div className={"content"}> </FormGroup>
<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>
<FormGroup className={"my-2"}> <FormGroup>
<FormLabel htmlFor={"color"}> <FormLabel htmlFor={"color"}>
{L("account.color")} {L("account.color")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<MuiColorInput <MuiColorInput
format={"hex"} format={"hex"}
value={group.color} value={group.color}
size={"small"} size={"small"}
variant={"outlined"} variant={"outlined"}
onChange={color => setGroup({...group, color: color})} onChange={color => setGroup({...group, color: color})}
/> />
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<ButtonBar mt={2}> <ButtonBar mt={2}>
<Button startIcon={<KeyboardArrowLeft />} <Button startIcon={<KeyboardArrowLeft />}
variant={"outlined"} variant={"outlined"}
onClick={() => navigate("/admin/groups")}> onClick={() => navigate("/admin/groups")}>
{L("general.go_back")} {L("general.go_back")}
</Button> </Button>
<Button startIcon={isSaving ? <CircularProgress size={14} /> : <Save />} <Button startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
color={"primary"} color={"primary"}
variant={"outlined"} variant={"outlined"}
disabled={isSaving || (!api.hasPermission(isNewGroup ? "groups/create" : "groups/update"))} disabled={isSaving || (!api.hasPermission(isNewGroup ? "groups/create" : "groups/update"))}
onClick={onSave}> onClick={onSave}>
{isSaving ? L("general.saving") + "…" : L("general.save")} {isSaving ? L("general.saving") + "…" : L("general.save")}
</Button> </Button>
{ !isNewGroup && { !isNewGroup &&
<Button startIcon={<Delete/>} disabled={!api.hasPermission("groups/delete")} <Button startIcon={<Delete/>} disabled={!api.hasPermission("groups/delete")}
variant={"outlined"} color={"secondary"} variant={"outlined"} color={"secondary"}
onClick={() => setDialogData({ onClick={() => setDialogData({
open: true, open: true,
title: L("account.delete_group_title"), title: L("account.delete_group_title"),
message: L("account.delete_group_text"), message: L("account.delete_group_text"),
onOption: option => option === 0 ? onDeleteGroup() : true onOption: option => option === 0 ? onDeleteGroup() : true
})}> })}>
{L("general.delete")} {L("general.delete")}
</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}
@ -306,6 +294,5 @@ export default function EditGroupView(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")]} />
</> </>
} }

@ -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,42 +47,25 @@ 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"}> <DataTable
<h1 className={"m-0 text-dark"}>{L("account.groups")}</h1> data={groups}
</div> pagination={pagination}
<div className={"col-sm-6"}> defaultSortOrder={"asc"}
<ol className={"breadcrumb float-sm-right"}> defaultSortColumn={0}
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> fetchData={onFetchGroups}
<li className="breadcrumb-item active">{L("account.groups")}</li> placeholder={"No groups to display"}
</ol> columns={columnDefinitions}
</div> buttons={[{
</div> key: "btn-create-group",
</div> color: "primary",
<div className={"content"}> startIcon: <Add />,
<div className={"container-fluid"}> onClick: () => navigate("/admin/group/new"),
<DataTable disabled: !api.hasPermission("groups/create"),
data={groups} children: L("general.create_new")
pagination={pagination} }]}/>
defaultSortOrder={"asc"} </ViewContent>
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>
</>
} }

@ -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,74 +107,63 @@ 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"}> <FormLabel>{L("logs.severity")}</FormLabel>
<ol className={"breadcrumb float-sm-right"}> <FormControl>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> <TextField select variant={"outlined"} size={"small"} value={logLevel}
<li className="breadcrumb-item active">{L("logs.title")}</li> onChange={e => setLogLevel(parseInt(e.target.value))}
</ol> inputProps={{size: "small"}}>
</div> {LOG_LEVELS.map((value, index) =>
</div> <MenuItem key={"option-" + value} value={index}>{value}</MenuItem>)
</div> }
<div className={"content overflow-auto"}> </TextField>
<div className={"row p-2"}> </FormControl>
<FormGroup className={"col-2"}> </FormGroup>
<FormLabel>{L("logs.severity")}</FormLabel> </Grid>
<FormControl> <Grid item xs={4}>
<TextField select variant={"outlined"} size={"small"} value={logLevel} <FormGroup>
onChange={e => setLogLevel(parseInt(e.target.value))} <FormLabel>{L("logs.timestamp")}</FormLabel>
inputProps={{ size: "small" }}> <FormControl>
{LOG_LEVELS.map((value, index) => <LocalizationProvider dateAdapter={AdapterDateFns}>
<MenuItem key={"option-" + value} value={index}>{value}</MenuItem>) <DateTimePicker label={L("logs.timestamp_placeholder") + "…"}
} value={timestamp ? toDate(new Date()) : null}
</TextField> format={L("general.datefns_datetime_format_precise")}
</FormControl> onChange={(newValue) => setTimestamp(newValue)}
</FormGroup> slotProps={{textField: {size: 'small'}}}
<FormGroup className={"col-4"}> sx={{"& .MuiInputBase-input": {height: "23px", padding: 1}}}
<FormLabel>{L("logs.timestamp")}</FormLabel> />
<FormControl> </LocalizationProvider>
<LocalizationProvider dateAdapter={AdapterDateFns}> </FormControl>
<DateTimePicker label={L("logs.timestamp_placeholder") + "…"} </FormGroup>
value={timestamp ? toDate(new Date()) : null} </Grid>
format={L("general.datefns_datetime_format_precise")} <Grid item xs={6}>
onChange={(newValue) => setTimestamp(newValue)} <FormGroup>
slotProps={{ textField: { size:'small' } }} <FormLabel>{L("logs.search")}</FormLabel>
sx={{"& .MuiInputBase-input": { height: "23px", padding: 1 }}} <FormControl>
/> <TextField
</LocalizationProvider> placeholder={L("logs.search_query") + "…"}
</FormControl> value={query}
</FormGroup> onChange={e => setQuery(e.target.value)}
<FormGroup className={"col-6"}> variant={"outlined"}
<FormLabel>{L("logs.search")}</FormLabel> size={"small"}/>
<FormControl> </FormControl>
<TextField </FormGroup>
placeholder={L("logs.search_query") + "…"} </Grid>
value={query} </Grid>
onChange={e => setQuery(e.target.value)} <DataTable
variant={"outlined"} data={logEntries}
size={"small"}/> pagination={pagination}
</FormControl> fetchData={onFetchLogs}
</FormGroup> forceReload={forceReload}
</div> defaultSortColumn={3}
<div className={"container-fluid"}> defaultSortOrder={"desc"}
<DataTable placeholder={L("logs.no_entries_placeholder")}
data={logEntries} columns={columnDefinitions}/>
pagination={pagination} </ViewContent>
className={"table table-striped"}
fetchData={onFetchLogs}
forceReload={forceReload}
defaultSortColumn={3}
defaultSortOrder={"desc"}
placeholder={L("logs.no_entries_placeholder")}
columns={columnDefinitions} />
</div>
</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> </Box>
</div> </StyledStatBox>
</div>
const StatusLine = (props) => { const StatusLine = (props) => {
const {enabled, text, ...other} = props; const {enabled, text, ...other} = props;
@ -84,96 +123,88 @@ 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> <StatBox color={"info"} count={stats?.userCount}
</div> text={L("admin.users_registered")}
<div className={"col-sm-6"}> icon={<People/>}
<ol className={"breadcrumb float-sm-right"}> link={"/admin/users"}/>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> </Grid>
<li className="breadcrumb-item active">{L("admin.dashboard")}</li> <Grid item xs={3}>
</ol> <StatBox color={"success"} count={stats?.groupCount}
</div> text={L("admin.available_groups")}
</div> icon={<Groups/>}
</div> link={"/admin/groups"}/>
</div> </Grid>
<section className={"content"}> <Grid item xs={3}>
<div className={"container-fluid"}> <StatBox color={"warning"} count={stats?.pageCount}
<div className={"row"}> text={L("admin.routes_defined")}
<StatBox color={"info"} count={stats?.userCount} icon={<LibraryBooks/>}
text={L("admin.users_registered")} link={"/admin/routes"}/>
icon={<People />} </Grid>
link={"/admin/users"} /> <Grid item xs={3}>
<StatBox color={"success"} count={stats?.groupCount} <StatBox color={"error"} count={stats?.errorCount}
text={L("admin.available_groups")} text={L("admin.error_count")}
icon={<Groups />} icon={<BugReport />}
link={"/admin/groups"} /> link={"/admin/logs"}/>
<StatBox color={"warning"} count={stats?.pageCount} </Grid>
text={L("admin.routes_defined")} </Grid>
icon={<LibraryBooks />} <Box m={2} p={2} component={Paper}>
link={"/admin/routes"} /> <h4>Server Stats</h4>
<StatBox color={"danger"} count={stats?.errorCount} <Divider />
text={L("admin.error_count")} {stats === null ? <CircularProgress/> :
icon={<BugReport />} <Table>
link={"/admin/logs"} /> <TableBody>
</div> <TableRow>
</div> <TableCell>Web-Base Version</TableCell>
<Box m={2} p={2} component={Paper}> <TableCell>{stats.server.version}</TableCell>
<h4>Server Stats</h4><hr /> </TableRow>
{stats === null ? <CircularProgress /> : <TableRow>
<Table> <TableCell>Server</TableCell>
<TableBody> <TableCell>{stats.server.server ?? "Unknown"}</TableCell>
<TableRow> </TableRow>
<TableCell>Web-Base Version</TableCell> <TableRow>
<TableCell>{stats.server.version}</TableCell> <TableCell>Load Average</TableCell>
</TableRow> <TableCell>{loadAvg}</TableCell>
<TableRow> </TableRow>
<TableCell>Server</TableCell> <TableRow>
<TableCell>{stats.server.server ?? "Unknown"}</TableCell> <TableCell>Memory Usage</TableCell>
</TableRow> <TableCell>{humanReadableSize(stats.server.memoryUsage)}</TableCell>
<TableRow> </TableRow>
<TableCell>Load Average</TableCell> <TableRow>
<TableCell>{loadAvg}</TableCell> <TableCell>Database</TableCell>
</TableRow> <TableCell>{stats.server.database}</TableCell>
<TableRow> </TableRow>
<TableCell>Memory Usage</TableCell> <TableRow>
<TableCell>{humanReadableSize(stats.server.memoryUsage)}</TableCell> <TableCell>Captcha</TableCell>
</TableRow> <TableCell>
<TableRow> <StatusLine enabled={!!stats.server.captcha}
<TableCell>Database</TableCell> text={L("settings." + (stats.server.captcha ? stats.server.captcha.name : "disabled"))}
<TableCell>{stats.server.database}</TableCell> />
</TableRow> </TableCell>
<TableRow> </TableRow>
<TableCell>Captcha</TableCell> <TableRow>
<TableCell> <TableCell>Mail</TableCell>
<StatusLine enabled={!!stats.server.captcha} <TableCell>
text={L("settings." + (stats.server.captcha ? stats.server.captcha.name : "disabled"))} <StatusLine enabled={!!stats.server.mail}
/> text={L("settings." + (stats.server.mail ? "enabled" : "disabled"))}
</TableCell> />
</TableRow> </TableCell>
<TableRow> </TableRow>
<TableCell>Mail</TableCell> <TableRow>
<TableCell> <TableCell>Rate-Limiting</TableCell>
<StatusLine enabled={!!stats.server.mail} <TableCell>
text={L("settings." + (stats.server.mail ? "enabled" : "disabled"))} <StatusLine enabled={!!stats.server.rateLimiting}
/> text={L("settings." + (stats.server.rateLimiting ? "enabled" : "disabled"))}
</TableCell> />
</TableRow> </TableCell>
<TableRow> </TableRow>
<TableCell>Rate-Limiting</TableCell> </TableBody>
<TableCell> </Table>
<StatusLine enabled={!!stats.server.rateLimiting} }
text={L("settings." + (stats.server.rateLimiting ? "enabled" : "disabled"))} </Box>
/> </ViewContent>
</TableCell>
</TableRow>
</TableBody>
</Table>
}
</Box>
</section>
</>
} }

@ -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} />
@ -104,9 +91,9 @@ export default function ProfileView(props) {
<FormLabel>{L("account.username")}</FormLabel> <FormLabel>{L("account.username")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
size={"small"} size={"small"}
value={profile.name} value={profile.name}
onChange={e => setProfile({...profile, name: e.target.value })} /> onChange={e => setProfile({...profile, name: e.target.value })} />
</FormControl> </FormControl>
</SpacedFormGroup> </SpacedFormGroup>
<SpacedFormGroup> <SpacedFormGroup>
@ -148,14 +135,13 @@ export default function ProfileView(props) {
<Box mt={2}> <Box mt={2}>
<Button variant={"outlined"} color={"primary"} <Button variant={"outlined"} color={"primary"}
disabled={isSaving || !api.hasPermission("user/updateProfile")} disabled={isSaving || !api.hasPermission("user/updateProfile")}
startIcon={isSaving ? <CircularProgress size={12} /> : <Save />} startIcon={isSaving ? <CircularProgress size={12} /> : <Save />}
onClick={onUpdateProfile}> onClick={onUpdateProfile}>
{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,42 +114,35 @@ 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> <RouteForm route={route} setRoute={setRoute} />
</div> <ButtonBar mt={2}>
<div className={"content"}> <Button startIcon={<KeyboardArrowLeft />}
<div className={"container-fluid"}> variant={"outlined"}
<h3>{L(isNewRoute ? "routes.create_route_title" : "routes.edit_route_title")}</h3> onClick={() => navigate("/admin/routes")}>
</div> {L("general.cancel")}
</div> </Button>
<RouteForm route={route} setRoute={setRoute} /> <Button startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
<ButtonBar mt={2}> color={"primary"}
<Button startIcon={<KeyboardArrowLeft />} variant={"outlined"}
variant={"outlined"} disabled={isSaving}
onClick={() => navigate("/admin/routes")}> onClick={onSave}>
{L("general.cancel")} {isSaving ? L("general.saving") + "…" : L("general.save")}
</Button> </Button>
<Button startIcon={isSaving ? <CircularProgress size={14} /> : <Save />} </ButtonBar>
color={"primary"} <Box mt={3}>
variant={"outlined"} <h5>{L("routes.validate_route")}</h5>
disabled={isSaving} <MonoSpaceTextField value={routeTest} onChange={e => setRouteTest(e.target.value)}
onClick={onSave}> variant={"outlined"} size={"small"} fullWidth={true}
{isSaving ? L("general.saving") + "…" : L("general.save")} placeholder={L("routes.validate_route_placeholder") + "…"} />
</Button> <pre>
</ButtonBar> Match: {JSON.stringify(routeTestResult)}
<Box mt={3}> </pre>
<h5>{L("routes.validate_route")}</h5> </Box>
<MonoSpaceTextField value={routeTest} onChange={e => setRouteTest(e.target.value)} </ViewContent>
variant={"outlined"} size={"small"} fullWidth={true}
placeholder={L("routes.validate_route_placeholder") + "…"} />
<pre>
Match: {JSON.stringify(routeTestResult)}
</pre>
</Box>
</div>
} }

@ -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,101 +120,89 @@ 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"}> startIcon={<Refresh />} onClick={() => onFetchRoutes(true)}>
<ol className={"breadcrumb float-sm-right"}> {L("general.reload")}
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> </Button>
<li className="breadcrumb-item active">{L("routes.title")}</li> <Button variant={"outlined"} startIcon={<Add />} size={"small"}
</ol> disabled={!props.api.hasPermission("routes/add")}
</div> onClick={() => navigate("/admin/routes/new")} >
</div> {L("general.add")}
</div> </Button>
</div> <Button variant={"outlined"} startIcon={<Cached />} size={"small"}
<div className={"row"}> disabled={!props.api.hasPermission("routes/generateCache") || isGeneratingCache}
<div className={"col-6"} /> onClick={onRegenerateCache} >
<div className={"col-6 text-right"}> {isGeneratingCache ? L("routes.regenerating_cache") + "…" : L("routes.regenerate_cache")}
<div className={"form-group"}> </Button>
<Button variant={"outlined"} color={"primary"} className={"m-1"} size={"small"} </ButtonBar>
startIcon={<Refresh />} onClick={() => onFetchRoutes(true)}> <TableContainer component={Paper} sx={{overflowX: "initial"}}>
{L("general.reload")} <Table stickyHeader size={"small"}>
</Button> <TableHead>
<Button variant={"outlined"} className={"m-1"} startIcon={<Add />} size={"small"} <TableRow>
disabled={!props.api.hasPermission("routes/add")} <TableCell>{L("general.id")}</TableCell>
onClick={() => navigate("/admin/routes/new")} > <TableCell>{L("routes.route")}</TableCell>
{L("general.add")} <TableCell>{L("routes.type")}</TableCell>
</Button> <TableCell>{L("routes.target")}</TableCell>
<Button variant={"outlined"} className={"m-1"} startIcon={<Cached />} size={"small"} <TableCell>{L("routes.extra")}</TableCell>
disabled={!props.api.hasPermission("routes/generateCache") || isGeneratingCache} <TableCell align={"center"}>{L("routes.active")}</TableCell>
onClick={onRegenerateCache} > <TableCell align={"center"}>{L("routes.exact")}</TableCell>
{isGeneratingCache ? L("routes.regenerating_cache") + "…" : L("routes.regenerate_cache")} <TableCell align={"center"}>{L("general.controls")}</TableCell>
</Button> </TableRow>
</div> </TableHead>
</div> <TableBodyStriped>
</div> {Object.entries(routes).map(([id, route]) =>
<TableContainer component={Paper} style={{overflowX: "initial"}}> <RouteTableRow key={"route-" + id}>
<Table stickyHeader size={"small"} className={"table-striped"}> <TableCell>{route.id}</TableCell>
<TableHead> <TableCell>{route.pattern}</TableCell>
<TableRow> <TableCell>{route.type}</TableCell>
<TableCell>{L("general.id")}</TableCell> <TableCell>{route.target}</TableCell>
<TableCell>{L("routes.route")}</TableCell> <TableCell>{route.extra}</TableCell>
<TableCell>{L("routes.type")}</TableCell> <TableCell align={"center"}>
<TableCell>{L("routes.target")}</TableCell> <Checkbox checked={route.active}
<TableCell>{L("routes.extra")}</TableCell> size={"small"}
<TableCell align={"center"}>{L("routes.active")}</TableCell> disabled={!api.hasPermission(route.active ? "routes/disable" : "routes/enable")}
<TableCell align={"center"}>{L("routes.exact")}</TableCell> onChange={(e) => onToggleRoute(route.id, e.target.checked)} />
<TableCell align={"center"}>{L("general.controls")}</TableCell> </TableCell>
</TableRow> <TableCell align={"center"}><BoolCell checked={route.exact} /></TableCell>
</TableHead> <TableCell align={"center"}>
<TableBody> <IconButton size={"small"} title={L("general.edit")}
{Object.entries(routes).map(([id, route]) => disabled={!api.hasPermission("routes/add")}
<RouteTableRow key={"route-" + id}> color={"primary"}
<TableCell>{route.id}</TableCell> onClick={() => navigate("/admin/routes/" + id)}>
<TableCell>{route.pattern}</TableCell> <Edit />
<TableCell>{route.type}</TableCell> </IconButton>
<TableCell>{route.target}</TableCell> <IconButton size={"small"} title={L("general.delete")}
<TableCell>{route.extra}</TableCell> disabled={!api.hasPermission("routes/remove")}
<TableCell align={"center"}> color={"secondary"}
<Checkbox checked={route.active} onClick={() => setDialogData({
size={"small"} open: true,
disabled={!api.hasPermission(route.active ? "routes/disable" : "routes/enable")} title: L("routes.delete_route_title"),
onChange={(e) => onToggleRoute(route.id, e.target.checked)} /> message: L("routes.delete_route_text"),
</TableCell> inputs: [
<TableCell align={"center"}><BoolCell checked={route.exact} /></TableCell> { type: "text", name: "pattern", value: route.pattern, disabled: true}
<TableCell align={"center"}> ],
<IconButton size={"small"} title={L("general.edit")} options: [L("general.ok"), L("general.cancel")],
disabled={!api.hasPermission("routes/add")} onOption: btn => btn === 0 ? onDeleteRoute(route.id) : true
color={"primary"} })}>
onClick={() => navigate("/admin/routes/" + id)}> <Delete />
<Edit /> </IconButton>
</IconButton> </TableCell>
<IconButton size={"small"} title={L("general.delete")} </RouteTableRow>
disabled={!api.hasPermission("routes/remove")} )}
color={"secondary"} </TableBodyStriped>
onClick={() => setDialogData({ </Table>
open: true, </TableContainer>
title: L("routes.delete_route_title"), </ViewContent>
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>
<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,57 +375,42 @@ 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"}> <Tabs value={selectedTab} onChange={(e, v) => setSelectedTab(v)} component={Paper}>
<h1 className={"m-0 text-dark"}>{L("settings.title")}</h1> <Tab value={"general"} label={L("settings.general")}
</div> icon={<SettingsApplications />} iconPosition={"start"} />
<div className={"col-sm-6"}> <Tab value={"mail"} label={L("settings.mail")}
<ol className={"breadcrumb float-sm-right"}> icon={<Mail />} iconPosition={"start"} />
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> <Tab value={"captcha"} label={L("settings.captcha")}
<li className="breadcrumb-item active">{L("settings.title")}</li> icon={<SmartToy />} iconPosition={"start"} />
</ol> <Tab value={"redis"} label={L("settings.rate_limit")}
</div> icon={<Storage />} iconPosition={"start"} />
</div> <Tab value={"uncategorized"} label={L("settings.uncategorized")}
</div> icon={<LibraryBooks />} iconPosition={"start"} />
</div> </Tabs>
<div className={"content"}> <Box p={2}>
<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() renderTab()
} }
</Box> </Box>
<ButtonBar> <ButtonBar>
<Button color={"primary"} <Button color={"primary"}
onClick={onSaveSettings} onClick={onSaveSettings}
disabled={isSaving || !api.hasPermission("settings/set")} disabled={isSaving || !api.hasPermission("settings/set")}
startIcon={isSaving ? <CircularProgress size={14} /> : <Save />} startIcon={isSaving ? <CircularProgress size={14} /> : <Save />}
variant={"outlined"} title={L(hasChanged ? "general.unsaved_changes" : "general.save")}> variant={"outlined"} title={L(hasChanged ? "general.unsaved_changes" : "general.save")}>
{isSaving ? L("general.saving") + "…" : (L("general.save") + (hasChanged ? " *" : ""))} {isSaving ? L("general.saving") + "…" : (L("general.save") + (hasChanged ? " *" : ""))}
</Button> </Button>
<Button color={"secondary"} <Button color={"secondary"}
onClick={onReset} onClick={onReset}
disabled={isSaving} disabled={isSaving}
startIcon={<RestartAlt />} startIcon={<RestartAlt />}
variant={"outlined"} title={L("general.reset")}> variant={"outlined"} title={L("general.reset")}>
{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,43 +69,25 @@ 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"}> <DataTable
<h1 className={"m-0 text-dark"}>{L("account.users")}</h1> data={users}
</div> pagination={pagination}
<div className={"col-sm-6"}> defaultSortOrder={"asc"}
<ol className={"breadcrumb float-sm-right"}> defaultSortColumn={0}
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> fetchData={onFetchUsers}
<li className="breadcrumb-item active">{L("account.users")}</li> placeholder={L("account.user_list_placeholder")}
</ol> columns={columnDefinitions}
</div> buttons={[{
</div> key: "btn-create",
</div> color: "primary",
</div> startIcon: <Add />,
<div className={"content"}> children: L("general.create_new"),
<div className={"container-fluid"}> disabled: !api.hasPermission("user/create") && !api.hasPermission("user/invite"),
onClick: () => navigate("/admin/user/new")
<DataTable }]}/>
data={users} </ViewContent>
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>
</>
} }

@ -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;