Removed AdminLTE, some minor improvements

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

View File

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

View File

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

View File

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

View File

@@ -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>
)
}

View File

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

View File

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

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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