From 0418118841209a4a871ebd2c1f99c6fcafabe645 Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 14 Jan 2023 09:51:46 +0100 Subject: [PATCH] frontend update --- react/admin-panel/src/App.jsx | 5 +- react/admin-panel/src/views/group-list.js | 2 +- react/admin-panel/src/views/overview.js | 2 - react/admin-panel/src/views/user-list.js | 4 +- react/package.json | 2 +- react/shared/api.js | 55 +++- react/shared/constants.js | 7 + react/shared/elements/data-table.js | 9 +- react/shared/elements/dialog.jsx | 47 +++ react/shared/elements/language-selection.js | 52 ++++ react/shared/locale.js | 4 +- react/shared/util.js | 36 ++- react/shared/views/login.jsx | 308 ++++++++++++++++++++ 13 files changed, 518 insertions(+), 15 deletions(-) create mode 100644 react/shared/constants.js create mode 100644 react/shared/elements/dialog.jsx create mode 100644 react/shared/elements/language-selection.js create mode 100644 react/shared/views/login.jsx diff --git a/react/admin-panel/src/App.jsx b/react/admin-panel/src/App.jsx index f555378..2190100 100644 --- a/react/admin-panel/src/App.jsx +++ b/react/admin-panel/src/App.jsx @@ -1,15 +1,16 @@ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import API from "shared/api"; import Icon from "shared/elements/icon"; -import LoginForm from "./views/login"; +import LoginForm from "shared/views/login"; import {Alert} from "@material-ui/lab"; import {Button} from "@material-ui/core"; -import { LocaleContext } from "shared/locale"; +import {LocaleContext} from "shared/locale"; import AdminDashboard from "./AdminDashboard"; export default function App() { const api = useMemo(() => new API(), []); + const [user, setUser] = useState(null); const [loaded, setLoaded] = useState(false); const [info, setInfo] = useState({}); diff --git a/react/admin-panel/src/views/group-list.js b/react/admin-panel/src/views/group-list.js index 675500d..a1ab3fa 100644 --- a/react/admin-panel/src/views/group-list.js +++ b/react/admin-panel/src/views/group-list.js @@ -32,7 +32,7 @@ export default function GroupListView(props) { const actionColumn = (() => { let column = new DataColumn(L("general.actions"), null, false); - column.renderData = (entry) => <> + column.renderData = (L, entry) => <> navigate("/admin/group/" + entry.id)}> diff --git a/react/admin-panel/src/views/overview.js b/react/admin-panel/src/views/overview.js index b28be77..569116b 100644 --- a/react/admin-panel/src/views/overview.js +++ b/react/admin-panel/src/views/overview.js @@ -1,8 +1,6 @@ import * as React from "react"; import {Link} from "react-router-dom"; 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 {useCallback, useEffect, useState} from "react"; diff --git a/react/admin-panel/src/views/user-list.js b/react/admin-panel/src/views/user-list.js index 049217d..e1a2beb 100644 --- a/react/admin-panel/src/views/user-list.js +++ b/react/admin-panel/src/views/user-list.js @@ -33,7 +33,7 @@ export default function UserListView(props) { const groupColumn = (() => { let column = new DataColumn(L("account.groups"), "groups"); - column.renderData = (entry) => { + column.renderData = (L, entry) => { return Object.values(entry.groups).map(group => ) } return column; @@ -41,7 +41,7 @@ export default function UserListView(props) { const actionColumn = (() => { let column = new DataColumn(L("general.actions"), null, false); - column.renderData = (entry) => <> + column.renderData = (L, entry) => <> navigate("/admin/user/" + entry.id)}> diff --git a/react/package.json b/react/package.json index 50279fb..3ea199a 100644 --- a/react/package.json +++ b/react/package.json @@ -48,7 +48,7 @@ "react-chartjs-2": "^5.0.1", "react-collapse": "^5.1.1", "react-dom": "^18.2.0", - "react-router-dom": "^6.4.3", + "react-router-dom": "^6.6.2", "sprintf-js": "^1.1.2" }, "browserslist": { diff --git a/react/shared/api.js b/react/shared/api.js index 5570bf9..22b1c08 100644 --- a/react/shared/api.js +++ b/react/shared/api.js @@ -1,8 +1,11 @@ +import {USER_GROUP_ADMIN} from "./constants"; + export default class API { constructor() { this.loggedIn = false; this.user = null; this.session = null; + this.permissions = []; } csrfToken() { @@ -26,6 +29,40 @@ export default class API { 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 **/ async info() { return this.apiCall("info"); @@ -41,6 +78,7 @@ export default class API { let data = await response.json(); if (data) { this.loggedIn = data["loggedIn"]; + this.permissions = data["permissions"] ? data["permissions"].map(s => s.toLowerCase()) : []; if (this.loggedIn) { this.session = data["session"]; this.user = data["user"]; @@ -63,6 +101,9 @@ export default class API { const res = await this.apiCall("user/logout"); if (res.success) { this.loggedIn = false; + this.permissions = []; + this.session = null; + this.user = null; } return res; @@ -164,7 +205,17 @@ export default class API { 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 }); + } }; \ No newline at end of file diff --git a/react/shared/constants.js b/react/shared/constants.js new file mode 100644 index 0000000..4f86571 --- /dev/null +++ b/react/shared/constants.js @@ -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; diff --git a/react/shared/elements/data-table.js b/react/shared/elements/data-table.js index 8db9838..c70223c 100644 --- a/react/shared/elements/data-table.js +++ b/react/shared/elements/data-table.js @@ -8,6 +8,7 @@ import "./data-table.css"; import {LocaleContext} from "../locale"; import clsx from "clsx"; import {Box} from "@mui/material"; +import {formatDate} from "../util"; export function DataTable(props) { @@ -86,7 +87,7 @@ export function DataTable(props) { for (const [key, entry] of Object.entries(data)) { let row = []; for (const [index, column] of columns.entries()) { - row.push({column.renderData(entry)}); + row.push({column.renderData(L, entry)}); } rows.push({ row }); @@ -269,7 +270,7 @@ export class DataColumn { throw new Error("Not implemented: compare"); } - renderData(entry) { + renderData(L, entry) { return entry[this.field] } @@ -320,4 +321,8 @@ export class DateTimeColumn extends DataColumn { return a - b; } + + renderData(L, entry) { + return formatDate(L, super.renderData(L, entry)); + } } \ No newline at end of file diff --git a/react/shared/elements/dialog.jsx b/react/shared/elements/dialog.jsx new file mode 100644 index 0000000..0bc909e --- /dev/null +++ b/react/shared/elements/dialog.jsx @@ -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( + + ) + } + + return ( +
onClose()}> +
e.stopPropagation()}> +
+
+

{props.title}

+ +
+
+

{props.message}

+
+
+ { buttons } +
+
+
+
+ ); +} \ No newline at end of file diff --git a/react/shared/elements/language-selection.js b/react/shared/elements/language-selection.js new file mode 100644 index 0000000..0b86ae0 --- /dev/null +++ b/react/shared/elements/language-selection.js @@ -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(); + } + } + + return + {L("general.language") + ": "} { flags } + +} \ No newline at end of file diff --git a/react/shared/locale.js b/react/shared/locale.js index 66c6dbe..0d404a7 100644 --- a/react/shared/locale.js +++ b/react/shared/locale.js @@ -33,7 +33,7 @@ function LocaleProvider(props) { const [entries, dispatch] = useReducer(reducer, window.languageEntries || {}); const [currentLocale, setCurrentLocale] = useState(window.languageCode || "en_US"); - const translate = useCallback((key) => { + const translate = useCallback((key, defaultTranslation = null) => { if (currentLocale) { if (entries.hasOwnProperty(currentLocale)) { let [module, variable] = key.split("."); @@ -46,7 +46,7 @@ function LocaleProvider(props) { } } - return "[" + key + "]"; + return defaultTranslation || "[" + key + "]"; }, [currentLocale, entries]); const hasModule = useCallback((code, module) => { diff --git a/react/shared/util.js b/react/shared/util.js index 58c9e3e..9c92b73 100644 --- a/react/shared/util.js +++ b/react/shared/util.js @@ -1,3 +1,6 @@ +import {format, parse} from "date-fns"; +import {API_DATE_FORMAT, API_DATETIME_FORMAT} from "./constants"; + function humanReadableSize(bytes, dp = 1) { const thresh = 1024; @@ -44,4 +47,35 @@ const getBaseUrl = () => { return window.location.protocol + "//" + window.location.host; } -export { humanReadableSize, removeParameter, getParameter, encodeText, decodeText, getBaseUrl }; \ No newline at end of file +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 }; \ No newline at end of file diff --git a/react/shared/views/login.jsx b/react/shared/views/login.jsx new file mode 100644 index 0000000..279e45e --- /dev/null +++ b/react/shared/views/login.jsx @@ -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 <> +
{L("account.2fa_title")}: {api.user["2fa"].type}
+ { api.user["2fa"].type === "totp" ? + set2FACode(e.target.value)} + /> : <> + {L("account.2fa_text")} + + {tfaState !== 2 + ? + :
+
{L("general.something_went_wrong")}:
{tfaError}
+ +
+ } +
+ + } + { + error ? {error} : <> + } + + + + + + + + + + } + + return <> + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + } + label={L("account.remember_me")} + checked={rememberMe} onClick={(e) => setRememberMe(!rememberMe)} + /> + { + error + ? + {error} + {emailConfirmed === false + ? <> Click here to resend the confirmation email. + : <> + } + + : (successMessage + ? {successMessage} + : <>) + } + + + + + {L("account.forgot_password")} + + + { props.info.registrationAllowed ? + + + {L("account.register_text")} + + : <> + } + + + } + + if (!loaded) { + return {L("general.loading")}… + } + + let successMessage = getParameter("success"); + return +
+
+ + {"Logo"} + {props.info.siteName} + +
+
e.preventDefault()}> + { createForm() } + + +
+
+}