frontend update

This commit is contained in:
Roman 2023-01-14 09:51:46 +01:00
parent 84d79fcb3a
commit 0418118841
13 changed files with 518 additions and 15 deletions

@ -1,7 +1,7 @@
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 Icon from "shared/elements/icon";
import LoginForm from "./views/login"; import LoginForm from "shared/views/login";
import {Alert} from "@material-ui/lab"; import {Alert} from "@material-ui/lab";
import {Button} from "@material-ui/core"; import {Button} from "@material-ui/core";
import {LocaleContext} from "shared/locale"; import {LocaleContext} from "shared/locale";
@ -10,6 +10,7 @@ import AdminDashboard from "./AdminDashboard";
export default function App() { export default function App() {
const api = useMemo(() => new API(), []); const api = useMemo(() => new API(), []);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [info, setInfo] = useState({}); const [info, setInfo] = useState({});

@ -32,7 +32,7 @@ export default function GroupListView(props) {
const actionColumn = (() => { const actionColumn = (() => {
let column = new DataColumn(L("general.actions"), null, false); let column = new DataColumn(L("general.actions"), null, false);
column.renderData = (entry) => <> column.renderData = (L, entry) => <>
<IconButton size={"small"} title={L("general.edit")} onClick={() => navigate("/admin/group/" + entry.id)}> <IconButton size={"small"} title={L("general.edit")} onClick={() => navigate("/admin/group/" + entry.id)}>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>

@ -1,8 +1,6 @@
import * as React from "react"; import * as React from "react";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import {format, getDaysInMonth} from "date-fns"; import {format, getDaysInMonth} from "date-fns";
import {Collapse} from "react-collapse";
import {Bar} from "react-chartjs-2";
import {CircularProgress, Icon} from "@material-ui/core"; import {CircularProgress, Icon} from "@material-ui/core";
import {useCallback, useEffect, useState} from "react"; import {useCallback, useEffect, useState} from "react";

@ -33,7 +33,7 @@ export default function UserListView(props) {
const groupColumn = (() => { const groupColumn = (() => {
let column = new DataColumn(L("account.groups"), "groups"); let column = new DataColumn(L("account.groups"), "groups");
column.renderData = (entry) => { column.renderData = (L, entry) => {
return Object.values(entry.groups).map(group => <Chip key={"group-" + group.id} label={group.name}/>) return Object.values(entry.groups).map(group => <Chip key={"group-" + group.id} label={group.name}/>)
} }
return column; return column;
@ -41,7 +41,7 @@ export default function UserListView(props) {
const actionColumn = (() => { const actionColumn = (() => {
let column = new DataColumn(L("general.actions"), null, false); let column = new DataColumn(L("general.actions"), null, false);
column.renderData = (entry) => <> column.renderData = (L, entry) => <>
<IconButton size={"small"} title={L("general.edit")} onClick={() => navigate("/admin/user/" + entry.id)}> <IconButton size={"small"} title={L("general.edit")} onClick={() => navigate("/admin/user/" + entry.id)}>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>

@ -48,7 +48,7 @@
"react-chartjs-2": "^5.0.1", "react-chartjs-2": "^5.0.1",
"react-collapse": "^5.1.1", "react-collapse": "^5.1.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.4.3", "react-router-dom": "^6.6.2",
"sprintf-js": "^1.1.2" "sprintf-js": "^1.1.2"
}, },
"browserslist": { "browserslist": {

@ -1,8 +1,11 @@
import {USER_GROUP_ADMIN} from "./constants";
export default class API { export default class API {
constructor() { constructor() {
this.loggedIn = false; this.loggedIn = false;
this.user = null; this.user = null;
this.session = null; this.session = null;
this.permissions = [];
} }
csrfToken() { csrfToken() {
@ -26,6 +29,40 @@ export default class API {
return res; return res;
} }
hasPermission(method) {
if (!this.permissions) {
return false;
}
for (const permission of this.permissions) {
if (method.endsWith("*") && permission.toLowerCase().startsWith(method.toLowerCase().substr(0, method.length - 1))) {
return true;
} else if (method.toLowerCase() === permission.toLowerCase()) {
return true;
}
}
return false;
}
hasGroup(groupIdOrName) {
if (this.loggedIn && this.user?.groups) {
if (!isNaN(groupIdOrName) && (typeof groupIdOrName === 'string' && groupIdOrName.match(/^\d+$/))) {
return this.user.groups.hasOwnProperty(groupIdOrName);
} else {
let userGroups = Object.values(this.user.groups);
return userGroups.includes(groupIdOrName);
}
} else {
return false;
}
}
isAdmin() {
return this.hasGroup(USER_GROUP_ADMIN);
}
/** Info **/ /** Info **/
async info() { async info() {
return this.apiCall("info"); return this.apiCall("info");
@ -41,6 +78,7 @@ export default class API {
let data = await response.json(); let data = await response.json();
if (data) { if (data) {
this.loggedIn = data["loggedIn"]; this.loggedIn = data["loggedIn"];
this.permissions = data["permissions"] ? data["permissions"].map(s => s.toLowerCase()) : [];
if (this.loggedIn) { if (this.loggedIn) {
this.session = data["session"]; this.session = data["session"];
this.user = data["user"]; this.user = data["user"];
@ -63,6 +101,9 @@ export default class API {
const res = await this.apiCall("user/logout"); const res = await this.apiCall("user/logout");
if (res.success) { if (res.success) {
this.loggedIn = false; this.loggedIn = false;
this.permissions = [];
this.session = null;
this.user = null;
} }
return res; return res;
@ -164,7 +205,17 @@ export default class API {
return this.apiCall("language/getEntries", {code: code, modules: modules}); return this.apiCall("language/getEntries", {code: code, modules: modules});
} }
/* /** ApiKeyAPI **/
// API-Key API
async getApiKeys(showActiveOnly = false) {
return this.apiCall("apiKey/fetch", { showActiveOnly: showActiveOnly });
}
} */ async createApiKey() {
return this.apiCall("apiKey/create");
}
async revokeKey(id) {
return this.apiCall("apiKey/revoke", { id: id });
}
}; };

@ -0,0 +1,7 @@
export const API_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
export const API_DATE_FORMAT = "yyyy-MM-dd";
export const API_TIME_FORMAT = "HH:mm:ss";
export const USER_GROUP_ADMIN = 1;
export const USER_GROUP_SUPPORT = 2;
export const USER_GROUP_MODERATOR = 3;

@ -8,6 +8,7 @@ import "./data-table.css";
import {LocaleContext} from "../locale"; import {LocaleContext} from "../locale";
import clsx from "clsx"; import clsx from "clsx";
import {Box} from "@mui/material"; import {Box} from "@mui/material";
import {formatDate} from "../util";
export function DataTable(props) { export function DataTable(props) {
@ -86,7 +87,7 @@ export function DataTable(props) {
for (const [key, entry] of Object.entries(data)) { for (const [key, entry] of Object.entries(data)) {
let row = []; let row = [];
for (const [index, column] of columns.entries()) { for (const [index, column] of columns.entries()) {
row.push(<TableCell key={"col-" + index}>{column.renderData(entry)}</TableCell>); row.push(<TableCell key={"col-" + index}>{column.renderData(L, entry)}</TableCell>);
} }
rows.push(<TableRow key={"row-" + key}>{ row }</TableRow>); rows.push(<TableRow key={"row-" + key}>{ row }</TableRow>);
@ -269,7 +270,7 @@ export class DataColumn {
throw new Error("Not implemented: compare"); throw new Error("Not implemented: compare");
} }
renderData(entry) { renderData(L, entry) {
return entry[this.field] return entry[this.field]
} }
@ -320,4 +321,8 @@ export class DateTimeColumn extends DataColumn {
return a - b; return a - b;
} }
renderData(L, entry) {
return formatDate(L, super.renderData(L, entry));
}
} }

@ -0,0 +1,47 @@
import React from "react";
import clsx from "clsx";
export default function Dialog(props) {
const show = props.show;
const classes = ["modal", "fade"];
const style = { paddingRight: "12px", display: (show ? "block" : "none") };
const onClose = props.onClose || function() { };
const onOption = props.onOption || function() { };
const options = props.options || ["Close"];
let buttons = [];
for (let name of options) {
let type = "default";
if (name === "Yes") type = "warning";
else if(name === "No") type = "danger";
buttons.push(
<button type="button" key={"button-" + name} className={"btn btn-" + type}
data-dismiss={"modal"} onClick={() => { onClose(); onOption(name); }}>
{name}
</button>
)
}
return (
<div className={clsx(classes, show && "show")} style={style} aria-modal={"true"} onClick={() => onClose()}>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-content">
<div className="modal-header">
<h4 className="modal-title">{props.title}</h4>
<button type="button" className="close" data-dismiss="modal" aria-label="Close" onClick={() => onClose()}>
<span aria-hidden="true">×</span>
</button>
</div>
<div className="modal-body">
<p>{props.message}</p>
</div>
<div className="modal-footer">
{ buttons }
</div>
</div>
</div>
</div>
);
}

@ -0,0 +1,52 @@
import React, {useCallback, useContext, useState} from 'react';
import {Box} from "@material-ui/core";
import {makeStyles} from "@material-ui/core/styles";
import {LocaleContext} from "shared/locale";
const useStyles = makeStyles((theme) => ({
languageFlag: {
margin: theme.spacing(0.2),
cursor: "pointer",
border: 0,
}
}));
export default function LanguageSelection(props) {
const api = props.api;
const classes = useStyles();
const [languages, setLanguages] = useState(null);
const {translate: L, setLanguageByCode} = useContext(LocaleContext);
const onSetLanguage = useCallback((code) => {
setLanguageByCode(api, code).then((res) => {
if (!res.success) {
alert(res.msg);
}
});
}, []);
let flags = [];
if (languages === null) {
api.getLanguages().then((res) => {
if (res.success) {
setLanguages(res.languages);
} else {
setLanguages({});
alert(res.msg);
}
});
} else {
for (const language of Object.values(languages)) {
let key = `lang-${language.code}`;
flags.push(<button type={"button"} title={language.name} onClick={() => onSetLanguage(language.code)}
key={key} className={classes.languageFlag} >
<img alt={key} src={`/img/icons/lang/${language.code}.gif`} />
</button>);
}
}
return <Box mt={1}>
{L("general.language") + ": "} { flags }
</Box>
}

@ -33,7 +33,7 @@ function LocaleProvider(props) {
const [entries, dispatch] = useReducer(reducer, window.languageEntries || {}); const [entries, dispatch] = useReducer(reducer, window.languageEntries || {});
const [currentLocale, setCurrentLocale] = useState(window.languageCode || "en_US"); const [currentLocale, setCurrentLocale] = useState(window.languageCode || "en_US");
const translate = useCallback((key) => { const translate = useCallback((key, defaultTranslation = null) => {
if (currentLocale) { if (currentLocale) {
if (entries.hasOwnProperty(currentLocale)) { if (entries.hasOwnProperty(currentLocale)) {
let [module, variable] = key.split("."); let [module, variable] = key.split(".");
@ -46,7 +46,7 @@ function LocaleProvider(props) {
} }
} }
return "[" + key + "]"; return defaultTranslation || "[" + key + "]";
}, [currentLocale, entries]); }, [currentLocale, entries]);
const hasModule = useCallback((code, module) => { const hasModule = useCallback((code, module) => {

@ -1,3 +1,6 @@
import {format, parse} from "date-fns";
import {API_DATE_FORMAT, API_DATETIME_FORMAT} from "./constants";
function humanReadableSize(bytes, dp = 1) { function humanReadableSize(bytes, dp = 1) {
const thresh = 1024; const thresh = 1024;
@ -44,4 +47,35 @@ const getBaseUrl = () => {
return window.location.protocol + "//" + window.location.host; return window.location.protocol + "//" + window.location.host;
} }
export { humanReadableSize, removeParameter, getParameter, encodeText, decodeText, getBaseUrl }; const formatDate = (L, apiDate) => {
if (!(apiDate instanceof Date)) {
if (!isNaN(apiDate)) {
apiDate = new Date(apiDate);
} else {
apiDate = parse(apiDate, API_DATE_FORMAT, new Date());
}
}
return format(apiDate, L("general.date_format", "YYY/MM/dd"));
}
const formatDateTime = (L, apiDate) => {
if (!(apiDate instanceof Date)) {
if (!isNaN(apiDate)) {
apiDate = new Date(apiDate);
} else {
apiDate = parse(apiDate, API_DATETIME_FORMAT, new Date());
}
}
return format(apiDate, L("general.date_time_format", "YYY/MM/dd HH:mm:ss"));
}
const upperFirstChars = (str) => {
return str.split(" ")
.map(block => block.charAt(0).toUpperCase() + block.substring(1))
.join(" ");
}
export { humanReadableSize, removeParameter, getParameter, encodeText, decodeText, getBaseUrl,
formatDate, formatDateTime, upperFirstChars };

@ -0,0 +1,308 @@
import {
Box,
Button,
Checkbox, CircularProgress, Container,
FormControlLabel,
Grid,
Link,
TextField,
Typography
} from "@material-ui/core";
import {makeStyles} from '@material-ui/core/styles';
import {Alert} from '@material-ui/lab';
import React, {useCallback, useContext, useEffect, useState} from "react";
import ReplayIcon from '@material-ui/icons/Replay';
import LanguageSelection from "../elements/language-selection";
import {decodeText, encodeText, getParameter, removeParameter} from "shared/util";
import Icon from "shared/elements/icon";
import {LocaleContext} from "shared/locale";
const useStyles = makeStyles((theme) => ({
paper: {
marginTop: theme.spacing(8),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
avatar: {
margin: theme.spacing(2),
width: "60px",
height: "60px"
},
form: {
width: '100%', // Fix IE 11 issue.
marginTop: theme.spacing(1),
},
submit: {
margin: theme.spacing(3, 0, 2),
},
logo: {
marginRight: theme.spacing(3)
},
headline: {
width: "100%",
},
container: {
marginTop: theme.spacing(5),
paddingBottom: theme.spacing(1),
borderColor: theme.palette.primary.main,
borderStyle: "solid",
borderWidth: 1,
borderRadius: 5
},
buttons2FA: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
error2FA: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
"& > div": {
fontSize: 16
},
"& > button": {
marginTop: theme.spacing(1)
}
}
}));
export default function LoginForm(props) {
const api = props.api;
const classes = useStyles();
let [username, setUsername] = useState("");
let [password, setPassword] = useState("");
let [rememberMe, setRememberMe] = useState(true);
let [isLoggingIn, setLoggingIn] = useState(false);
let [emailConfirmed, setEmailConfirmed] = useState(null);
let [tfaCode, set2FACode] = useState("");
let [tfaState, set2FAState] = useState(0); // 0: not sent, 1: sent, 2: retry
let [tfaError, set2FAError] = useState("");
let [error, setError] = useState("");
let [loaded, setLoaded] = useState(false);
const {translate: L, currentLocale, requestModules} = useContext(LocaleContext);
const onUpdateLocale = useCallback(() => {
requestModules(api, ["general", "account"], currentLocale).then(data => {
setLoaded(true);
if (!data.success) {
alert(data.msg);
}
});
}, [currentLocale]);
useEffect(() => {
onUpdateLocale();
}, [currentLocale]);
const onLogin = useCallback(() => {
if (!isLoggingIn) {
setError("");
setLoggingIn(true);
removeParameter("success");
api.login(username, password, rememberMe).then((res) => {
set2FAState(0);
setLoggingIn(false);
setPassword("");
if (!res.success) {
setEmailConfirmed(res.emailConfirmed);
setError(res.msg);
} else {
props.onLogin();
}
});
}
}, [api, isLoggingIn, password, props, rememberMe, username]);
const onSubmit2FA = useCallback(() => {
setLoggingIn(true);
props.onTotp2FA(tfaCode, (res) => {
setLoggingIn(false);
});
}, [tfaCode, props]);
const onCancel2FA = useCallback(() => {
props.onLogout();
}, [props]);
useEffect(() => {
if (!api.loggedIn || !api.user) {
return;
}
let twoFactor = api.user["2fa"];
if (!twoFactor || !twoFactor.confirmed ||
twoFactor.authenticated || twoFactor.type !== "fido") {
return;
}
if (tfaState === 0) {
set2FAState(1);
set2FAError("");
navigator.credentials.get({
publicKey: {
challenge: encodeText(window.atob(twoFactor.challenge)),
allowCredentials: [{
id: encodeText(window.atob(twoFactor.credentialID)),
type: "public-key",
}],
userVerification: "discouraged",
},
}).then((res) => {
let credentialID = res.id;
let clientDataJson = decodeText(res.response.clientDataJSON);
let authData = window.btoa(decodeText(res.response.authenticatorData));
let signature = window.btoa(decodeText(res.response.signature));
props.onKey2FA(credentialID, clientDataJson, authData, signature, res => {
if (!res.success) {
set2FAState(2);
}
});
}).catch(e => {
set2FAState(2);
set2FAError(e.toString());
});
}
}, [api.loggedIn, api.user, tfaState, props]);
const createForm = () => {
// 2FA
if (api.loggedIn && api.user["2fa"]) {
return <>
<div>{L("account.2fa_title")}: {api.user["2fa"].type}</div>
{ api.user["2fa"].type === "totp" ?
<TextField
variant="outlined" margin="normal"
id="code" label={L("account.6_digit_code")} name="code"
autoComplete="code"
required fullWidth autoFocus
value={tfaCode} onChange={(e) => set2FACode(e.target.value)}
/> : <>
{L("account.2fa_text")}
<Box mt={2} textAlign={"center"}>
{tfaState !== 2
? <CircularProgress/>
: <div className={classes.error2FA}>
<div>{L("general.something_went_wrong")}:<br />{tfaError}</div>
<Button onClick={() => set2FAState(0)}
variant={"outlined"} color={"secondary"} size={"small"}>
<ReplayIcon />&nbsp;{L("general.retry")}
</Button>
</div>
}
</Box>
</>
}
{
error ? <Alert severity="error">{error}</Alert> : <></>
}
<Grid container spacing={2} className={classes.buttons2FA}>
<Grid item xs={6}>
<Button
fullWidth variant="contained"
color="inherit" size={"medium"}
disabled={isLoggingIn}
onClick={onCancel2FA}>
{L("general.go_back")}
</Button>
</Grid>
<Grid item xs={6}>
<Button
type="submit" fullWidth variant="contained"
color="primary" size={"medium"}
disabled={isLoggingIn || api.user["2fa"].type !== "totp"}
onClick={onSubmit2FA}>
{isLoggingIn ?
<>{L("general.submitting")} <CircularProgress size={15}/></> :
L("general.submit")
}
</Button>
</Grid>
</Grid>
</>
}
return <>
<TextField
variant="outlined" margin="normal"
id="username" label={L("account.username")} name="username"
autoComplete="username" disabled={isLoggingIn}
required fullWidth autoFocus
value={username} onChange={(e) => setUsername(e.target.value)}
/>
<TextField
variant="outlined" margin="normal"
name="password" label={L("account.password")} type="password" id="password"
autoComplete="current-password"
required fullWidth disabled={isLoggingIn}
value={password} onChange={(e) => setPassword(e.target.value)}
/>
<FormControlLabel
control={<Checkbox value="remember" color="primary"/>}
label={L("account.remember_me")}
checked={rememberMe} onClick={(e) => setRememberMe(!rememberMe)}
/>
{
error
? <Alert severity="error">
{error}
{emailConfirmed === false
? <> <Link href={"/resendConfirmation"}>Click here</Link> to resend the confirmation email.</>
: <></>
}
</Alert>
: (successMessage
? <Alert severity="success">{successMessage}</Alert>
: <></>)
}
<Button
type={"submit"} fullWidth variant={"contained"}
color={"primary"} className={classes.submit}
size={"large"}
disabled={isLoggingIn}
onClick={onLogin}>
{isLoggingIn ?
<>{L("account.signing_in")} <CircularProgress size={15}/></> :
L("account.sign_in")
}
</Button>
<Grid container>
<Grid item xs>
<Link href="/resetPassword" variant="body2">
{L("account.forgot_password")}
</Link>
</Grid>
{ props.info.registrationAllowed ?
<Grid item>
<Link href="/register" variant="body2">
{L("account.register_text")}
</Link>
</Grid> : <></>
}
</Grid>
</>
}
if (!loaded) {
return <b>{L("general.loading")} <Icon icon={"spinner"}/></b>
}
let successMessage = getParameter("success");
return <Container maxWidth={"xs"} className={classes.container}>
<div className={classes.paper}>
<div className={classes.headline}>
<Typography component="h1" variant="h4">
<img src={"/img/icons/logo.png"} alt={"Logo"} height={48} className={classes.logo}/>
{props.info.siteName}
</Typography>
</div>
<form className={classes.form} onSubmit={(e) => e.preventDefault()}>
{ createForm() }
<LanguageSelection api={api} />
</form>
</div>
</Container>
}