frontend update
This commit is contained in:
parent
84d79fcb3a
commit
0418118841
@ -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 });
|
||||||
|
}
|
||||||
};
|
};
|
7
react/shared/constants.js
Normal file
7
react/shared/constants.js
Normal file
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
47
react/shared/elements/dialog.jsx
Normal file
47
react/shared/elements/dialog.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
52
react/shared/elements/language-selection.js
Normal file
52
react/shared/elements/language-selection.js
Normal file
@ -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 };
|
308
react/shared/views/login.jsx
Normal file
308
react/shared/views/login.jsx
Normal file
@ -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 /> {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>
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user