frontend & backend update
This commit is contained in:
@@ -6,6 +6,7 @@ export default class API {
|
||||
this.loggedIn = false;
|
||||
this.user = null;
|
||||
this.session = null;
|
||||
this.language = { id: 1, code: "en_US", shortCode: "en", name: "American English" };
|
||||
this.permissions = [];
|
||||
}
|
||||
|
||||
@@ -80,24 +81,31 @@ export default class API {
|
||||
|
||||
/** UserAPI **/
|
||||
async login(username, password, rememberMe=false) {
|
||||
return this.apiCall("user/login", { username: username, password: password, stayLoggedIn: rememberMe })
|
||||
let res = await this.apiCall("user/login", { username: username, password: password, stayLoggedIn: rememberMe });
|
||||
if (res.success) {
|
||||
this.loggedIn = true;
|
||||
this.session = res.session;
|
||||
this.user = res.user;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async fetchUser() {
|
||||
let response = await fetch("/api/user/info");
|
||||
let data = await response.json();
|
||||
if (data) {
|
||||
this.loggedIn = data["loggedIn"];
|
||||
this.permissions = data["permissions"] ? data["permissions"].map(s => s.toLowerCase()) : [];
|
||||
let res = await this.apiCall("user/info");
|
||||
if (res.success) {
|
||||
this.loggedIn = res.loggedIn;
|
||||
this.language = res.language;
|
||||
this.permissions = (res.permissions || []).map(s => s.toLowerCase());
|
||||
if (this.loggedIn) {
|
||||
this.session = data["session"];
|
||||
this.user = data["user"];
|
||||
this.session = res.session;
|
||||
this.user = res.user;
|
||||
} else {
|
||||
this.session = null;
|
||||
this.user = null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
return res;
|
||||
}
|
||||
|
||||
async editUser(id, username, email, password, groups, confirmed) {
|
||||
@@ -147,6 +155,11 @@ export default class API {
|
||||
return this.apiCall("user/create", { username: username, email: email, password: password, confirmPassword: confirmPassword });
|
||||
}
|
||||
|
||||
async updateProfile(username=null, fullName=null, password=null, confirmPassword = null, oldPassword = null) {
|
||||
return this.apiCall("user/updateProfile", { username: username, fullName: fullName,
|
||||
password: password, confirmPassword: confirmPassword, oldPassword: oldPassword });
|
||||
}
|
||||
|
||||
/** Stats **/
|
||||
async getStats() {
|
||||
return this.apiCall("stats");
|
||||
@@ -204,7 +217,12 @@ export default class API {
|
||||
}
|
||||
|
||||
async setLanguage(params) {
|
||||
return await this.apiCall("language/set", params);
|
||||
let res = await this.apiCall("language/set", params);
|
||||
if (res.success) {
|
||||
this.language = res.language;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async getLanguageEntries(modules, code=null, useCache=false) {
|
||||
@@ -216,7 +234,6 @@ export default class API {
|
||||
}
|
||||
|
||||
/** ApiKeyAPI **/
|
||||
// API-Key API
|
||||
async getApiKeys(showActiveOnly = false) {
|
||||
return this.apiCall("apiKey/fetch", { showActiveOnly: showActiveOnly });
|
||||
}
|
||||
@@ -228,4 +245,42 @@ export default class API {
|
||||
async revokeKey(id) {
|
||||
return this.apiCall("apiKey/revoke", { id: id });
|
||||
}
|
||||
|
||||
/** 2FA API **/
|
||||
async confirmTOTP(code) {
|
||||
return this.apiCall("tfa/confirmTotp", { code: code });
|
||||
}
|
||||
|
||||
async remove2FA(password) {
|
||||
return this.apiCall("tfa/remove", { password: password });
|
||||
}
|
||||
|
||||
async verifyTotp2FA(code) {
|
||||
return this.apiCall("tfa/verifyTotp", { code: code });
|
||||
}
|
||||
|
||||
async verifyKey2FA(credentialID, clientDataJSON, authData, signature) {
|
||||
return this.apiCall("tfa/verifyKey", { credentialID: credentialID, clientDataJSON: clientDataJSON, authData: authData, signature: signature })
|
||||
}
|
||||
|
||||
async register2FA(clientDataJSON = null, attestationObject = null) {
|
||||
return this.apiCall("tfa/registerKey", { clientDataJSON: clientDataJSON, attestationObject: attestationObject });
|
||||
}
|
||||
|
||||
/** GPG API **/
|
||||
async uploadGPG(pubkey) {
|
||||
return this.apiCall("user/importGPG", { pubkey: pubkey });
|
||||
}
|
||||
|
||||
async confirmGpgToken(token) {
|
||||
return this.apiCall("user/confirmGPG", { token: token });
|
||||
}
|
||||
|
||||
async removeGPG(password) {
|
||||
return this.apiCall("user/removeGPG", { password: password });
|
||||
}
|
||||
|
||||
async downloadGPG(userId) {
|
||||
return this.apiCall("user/downloadGPG", { id: userId }, true);
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
}
|
||||
|
||||
.data-table td, .data-table th {
|
||||
padding: 2px;
|
||||
padding: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,17 @@
|
||||
background-color: #bbb;
|
||||
}
|
||||
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
.data-table th > svg {
|
||||
vertical-align: middle;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-table-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -2,48 +2,51 @@ import {Table, TableBody, TableCell, TableHead, TableRow} from "@material-ui/cor
|
||||
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
||||
import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
|
||||
import React, {useCallback, useContext, useEffect, useState} from "react";
|
||||
import usePagination from "../hooks/pagination";
|
||||
import {parse} from "date-fns";
|
||||
import "./data-table.css";
|
||||
import {LocaleContext} from "../locale";
|
||||
import clsx from "clsx";
|
||||
import {Box} from "@mui/material";
|
||||
import {formatDate} from "../util";
|
||||
import {Box, IconButton} from "@mui/material";
|
||||
import {formatDateTime} from "../util";
|
||||
import UserLink from "security-lab/src/elements/user/userlink";
|
||||
import CachedIcon from "@material-ui/icons/Cached";
|
||||
|
||||
|
||||
export function DataTable(props) {
|
||||
|
||||
const { className, placeholder,
|
||||
columns, data, pagination,
|
||||
fetchData, onClick, onFilter,
|
||||
defaultSortColumn, defaultSortOrder,
|
||||
columns, ...other } = props;
|
||||
title, ...other } = props;
|
||||
|
||||
const {currentLocale, requestModules, translate: L} = useContext(LocaleContext);
|
||||
const {translate: L} = useContext(LocaleContext);
|
||||
|
||||
const [doFetchData, setFetchData] = useState(false);
|
||||
const [data, setData] = useState(null);
|
||||
const [sortAscending, setSortAscending] = useState(["asc","ascending"].includes(defaultSortOrder?.toLowerCase));
|
||||
const [sortColumn, setSortColumn] = useState(defaultSortColumn || null);
|
||||
const pagination = usePagination();
|
||||
const sortable = props.hasOwnProperty("sortable") ? !!props.sortable : true;
|
||||
const onRowClick = onClick || (() => {});
|
||||
|
||||
const onFetchData = useCallback((force = false) => {
|
||||
if (doFetchData || force) {
|
||||
setFetchData(false);
|
||||
const orderBy = columns[sortColumn]?.field || null;
|
||||
const sortOrder = sortAscending ? "asc" : "desc";
|
||||
fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder).then(([data, dataPagination]) => {
|
||||
if (data) {
|
||||
setData(data);
|
||||
pagination.update(dataPagination);
|
||||
}
|
||||
});
|
||||
fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder);
|
||||
}
|
||||
}, [doFetchData, columns, sortColumn, sortAscending, pagination]);
|
||||
|
||||
// pagination changed?
|
||||
useEffect(() => {
|
||||
let forceFetch = (pagination.getPageSize() < pagination.getTotal());
|
||||
let forceFetch = false;
|
||||
if (pagination.getPageSize() < pagination.getTotal()) {
|
||||
// page size is smaller than the total count
|
||||
forceFetch = true;
|
||||
} else if (data?.length && pagination.getPageSize() >= data.length && data.length < pagination.getTotal()) {
|
||||
// page size is greater than the current visible count but there were hidden rows before
|
||||
forceFetch = true;
|
||||
}
|
||||
|
||||
onFetchData(forceFetch);
|
||||
}, [pagination.data.pageSize, pagination.data.current]);
|
||||
|
||||
@@ -69,13 +72,14 @@ export function DataTable(props) {
|
||||
}
|
||||
|
||||
if (sortable && column.sortable) {
|
||||
headerRow.push(<TableCell key={"col-" + index} className={"sortable"}
|
||||
headerRow.push(<TableCell key={"col-" + index} className={"data-table-clickable"}
|
||||
title={L("general.sort_by") + ": " + column.label}
|
||||
onClick={() => onChangeSort(index, column) }>
|
||||
onClick={() => onChangeSort(index, column)}
|
||||
align={column.align}>
|
||||
{sortColumn === index ? (sortAscending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />): <></>}{column.renderHead(index)}
|
||||
</TableCell>);
|
||||
} else {
|
||||
headerRow.push(<TableCell key={"col-" + index}>
|
||||
headerRow.push(<TableCell key={"col-" + index} align={column.align}>
|
||||
{column.renderHead(index)}
|
||||
</TableCell>);
|
||||
}
|
||||
@@ -83,14 +87,20 @@ export function DataTable(props) {
|
||||
|
||||
const numColumns = columns.length;
|
||||
let rows = [];
|
||||
if (data) {
|
||||
for (const [key, entry] of Object.entries(data)) {
|
||||
if (data && data?.length) {
|
||||
for (const [rowIndex, entry] of data.entries()) {
|
||||
let row = [];
|
||||
for (const [index, column] of columns.entries()) {
|
||||
row.push(<TableCell key={"col-" + index}>{column.renderData(L, entry)}</TableCell>);
|
||||
row.push(<TableCell key={"col-" + index} align={column.align}>
|
||||
{column.renderData(L, entry, index)}
|
||||
</TableCell>);
|
||||
}
|
||||
|
||||
rows.push(<TableRow key={"row-" + key}>{ row }</TableRow>);
|
||||
rows.push(<TableRow className={clsx({["data-table-clickable"]: typeof onClick === 'function'})}
|
||||
onClick={() => onRowClick(rowIndex, entry)}
|
||||
key={"row-" + rowIndex}>
|
||||
{ row }
|
||||
</TableRow>);
|
||||
}
|
||||
} else if (placeholder) {
|
||||
rows.push(<TableRow key={"row-placeholder"}>
|
||||
@@ -100,151 +110,13 @@ export function DataTable(props) {
|
||||
</TableRow>);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
||||
let columnElements = [];
|
||||
if (columns) {
|
||||
for (const [key, column] of Object.entries(columns)) {
|
||||
const centered = column.alignment === "center";
|
||||
const sortable = doSort && (!column.hasOwnProperty("sortable") || !!column.sortable);
|
||||
const label = column.label;
|
||||
|
||||
if (!sortable) {
|
||||
columnElements.push(
|
||||
<TableCell key={"column-" + key} className={clsx(centered && classes.columnCenter)}>
|
||||
{ label }
|
||||
</TableCell>
|
||||
);
|
||||
} else {
|
||||
columnElements.push(
|
||||
<TableCell key={"column-" + key} label={L("Sort By") + ": " + label} className={clsx(classes.clickable, centered && classes.columnCenter)}
|
||||
onClick={() => (key === sortColumn ? setSortAscending(!sortAscending) : setSortColumn(key)) }>
|
||||
{ key === sortColumn ?
|
||||
<Grid container alignItems={"center"} spacing={1} direction={"row"} className={classes.gridSorted}>
|
||||
<Grid item>{ sortAscending ? <ArrowUpwardIcon fontSize={"small"} /> : <ArrowDownwardIcon fontSize={"small"} /> }</Grid>
|
||||
<Grid item>{ label }</Grid>
|
||||
<Grid item/>
|
||||
</Grid> :
|
||||
<span><i/>{label}</span>
|
||||
}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getValue = useCallback((entry, key) => {
|
||||
if (typeof columns[key]?.value === 'function') {
|
||||
return columns[key].value(entry);
|
||||
} else {
|
||||
return entry[columns[key]?.value] ?? null;
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
let numColumns = columns ? Object.keys(columns).length : 0;
|
||||
|
||||
const compare = (a,b,callback) => {
|
||||
let definedA = a !== null && typeof a !== 'undefined';
|
||||
let definedB = b !== null && typeof b !== 'undefined';
|
||||
if (!definedA && !definedB) {
|
||||
return 0;
|
||||
} else if (!definedA) {
|
||||
return 1;
|
||||
} else if (!definedB) {
|
||||
return -1;
|
||||
} else {
|
||||
return callback(a,b);
|
||||
}
|
||||
}
|
||||
|
||||
let rows = [];
|
||||
const hasClickHandler = typeof onClick === 'function';
|
||||
if (data !== null && columns) {
|
||||
let hidden = 0;
|
||||
let sortedEntries = data.slice();
|
||||
|
||||
if (sortColumn && columns[sortColumn]) {
|
||||
let sortFunction;
|
||||
if (typeof columns[sortColumn]?.compare === 'function') {
|
||||
sortFunction = columns[sortColumn].compare;
|
||||
} else if (columns[sortColumn]?.type === Date) {
|
||||
sortFunction = (a, b) => compare(a, b, (a,b) => a.getTime() - b.getTime());
|
||||
} else if (columns[sortColumn]?.type === Number) {
|
||||
sortFunction = (a, b) => compare(a, b, (a,b) => a - b);
|
||||
} else {
|
||||
sortFunction = ((a, b) =>
|
||||
compare(a, b, (a,b) => a.toString().toLowerCase().localeCompare(b.toString().toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
sortedEntries.sort((a, b) => {
|
||||
let entryA = getValue(a, sortColumn);
|
||||
let entryB = getValue(b, sortColumn);
|
||||
return sortFunction(entryA, entryB);
|
||||
});
|
||||
|
||||
if (!sortAscending) {
|
||||
sortedEntries = sortedEntries.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
Array.from(Array(sortedEntries.length).keys()).forEach(rowIndex => {
|
||||
if (typeof props.filter === 'function' && !props.filter(sortedEntries[rowIndex])) {
|
||||
hidden++;
|
||||
return;
|
||||
}
|
||||
|
||||
let rowData = [];
|
||||
for (const [key, column] of Object.entries(columns)) {
|
||||
let value = getValue(sortedEntries[rowIndex], key);
|
||||
if (typeof column.render === 'function') {
|
||||
value = column.render(sortedEntries[rowIndex], value);
|
||||
}
|
||||
|
||||
rowData.push(<TableCell key={"column-" + key} className={clsx(column.alignment === "center" && classes.columnCenter)}>
|
||||
{ value }
|
||||
</TableCell>);
|
||||
}
|
||||
|
||||
rows.push(
|
||||
<TableRow key={"entry-" + rowIndex}
|
||||
className={clsx(hasClickHandler && classes.clickable)}
|
||||
onClick={() => hasClickHandler && onClick(sortedEntries[rowIndex])}>
|
||||
{ rowData }
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
if (hidden > 0) {
|
||||
rows.push(<TableRow key={"row-hidden"}>
|
||||
<TableCell colSpan={numColumns} className={classes.hidden}>
|
||||
{ "(" + (hidden > 1
|
||||
? sprintf(L("%d rows hidden due to filter"), hidden)
|
||||
: L("1 rows hidden due to filter")) + ")"
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>);
|
||||
} else if (rows.length === 0 && placeholder) {
|
||||
rows.push(<TableRow key={"row-placeholder"}>
|
||||
<TableCell colSpan={numColumns} className={classes.hidden}>
|
||||
{ placeholder }
|
||||
</TableCell>
|
||||
</TableRow>);
|
||||
}
|
||||
} else if (columns && data === null) {
|
||||
rows.push(<TableRow key={"loading"}>
|
||||
<TableCell colSpan={numColumns} className={classes.columnCenter}>
|
||||
<Grid container alignItems={"center"} spacing={1} justifyContent={"center"}>
|
||||
<Grid item>{L("Loading")}…</Grid>
|
||||
<Grid item><CircularProgress size={15}/></Grid>
|
||||
</Grid>
|
||||
</TableCell>
|
||||
</TableRow>)
|
||||
}
|
||||
*/
|
||||
|
||||
return <Box position={"relative"}>
|
||||
<h3>
|
||||
<IconButton onClick={() => onFetchData(true)}>
|
||||
<CachedIcon/>
|
||||
</IconButton>
|
||||
{title}
|
||||
</h3>
|
||||
<Table className={clsx("data-table", className)} size="small" {...other}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -260,17 +132,14 @@ export function DataTable(props) {
|
||||
}
|
||||
|
||||
export class DataColumn {
|
||||
constructor(label, field = null, sortable = true) {
|
||||
constructor(label, field = null, params = {}) {
|
||||
this.label = label;
|
||||
this.field = field;
|
||||
this.sortable = sortable;
|
||||
this.sortable = !params.hasOwnProperty("sortable") || !!params.sortable;
|
||||
this.align = params.align || "left";
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
throw new Error("Not implemented: compare");
|
||||
}
|
||||
|
||||
renderData(L, entry) {
|
||||
renderData(L, entry, index) {
|
||||
return entry[this.field]
|
||||
}
|
||||
|
||||
@@ -280,49 +149,88 @@ export class DataColumn {
|
||||
}
|
||||
|
||||
export class StringColumn extends DataColumn {
|
||||
constructor(label, field = null, sortable = true, caseSensitive = false) {
|
||||
super(label, field, sortable);
|
||||
this.caseSensitve = caseSensitive;
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
if (this.caseSensitve) {
|
||||
return a.toString().localeCompare(b.toString());
|
||||
} else {
|
||||
return a.toString().toLowerCase().localeCompare(b.toString().toLowerCase());
|
||||
}
|
||||
constructor(label, field = null, params = {}) {
|
||||
super(label, field, params);
|
||||
}
|
||||
}
|
||||
|
||||
export class NumericColumn extends DataColumn {
|
||||
constructor(label, field = null, sortable = true) {
|
||||
super(label, field, sortable);
|
||||
constructor(label, field = null, params = {}) {
|
||||
super(label, field, params);
|
||||
this.decimalDigits = params.decimalDigits || null;
|
||||
this.integerDigits = params.integerDigits || null;
|
||||
this.prefix = params.prefix || "";
|
||||
this.suffix = params.suffix || "";
|
||||
this.decimalChar = params.decimalChar || ".";
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
return a - b;
|
||||
renderData(L, entry, index) {
|
||||
let number = super.renderData(L, entry).toString();
|
||||
|
||||
if (this.decimalDigits !== null) {
|
||||
number = number.toFixed(this.decimalDigits);
|
||||
}
|
||||
|
||||
if (this.integerDigits !== null) {
|
||||
let currentLength = number.split(".")[0].length;
|
||||
if (currentLength < this.integerDigits) {
|
||||
number = number.padStart(this.integerDigits - currentLength, "0");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.decimalChar !== ".") {
|
||||
number = number.replace(".", this.decimalChar);
|
||||
}
|
||||
|
||||
return this.prefix + number + this.suffix;
|
||||
}
|
||||
}
|
||||
|
||||
export class DateTimeColumn extends DataColumn {
|
||||
constructor(label, field = null, sortable = true, format = "YYYY-MM-dd HH:mm:ss") {
|
||||
super(label, field, sortable);
|
||||
this.format = format;
|
||||
constructor(label, field = null, params = {}) {
|
||||
super(label, field, params);
|
||||
this.precise = !!params.precise;
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
if (typeof a === 'string') {
|
||||
a = parse(a, this.format, new Date()).getTime();
|
||||
}
|
||||
renderData(L, entry, index) {
|
||||
let date = super.renderData(L, entry);
|
||||
return formatDateTime(L, date, this.precise);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof b === 'string') {
|
||||
b = parse(b, this.format, new Date()).getTime();
|
||||
}
|
||||
|
||||
return a - b;
|
||||
export class UserLinkColumn extends DataColumn {
|
||||
constructor(label, field = null, params = {}) {
|
||||
super(label, field, params);
|
||||
}
|
||||
|
||||
renderData(L, entry) {
|
||||
return formatDate(L, super.renderData(L, entry));
|
||||
renderData(L, entry, index) {
|
||||
return <UserLink user={super.renderData(L, entry)}/>
|
||||
}
|
||||
}
|
||||
|
||||
export class ControlsColumn extends DataColumn {
|
||||
constructor(buttons = [], params = {}) {
|
||||
super("general.controls", null, { align: "center", ...params, sortable: false });
|
||||
this.buttons = buttons;
|
||||
}
|
||||
|
||||
renderData(L, entry, index) {
|
||||
let buttonElements = [];
|
||||
for (const [index, button] of this.buttons.entries()) {
|
||||
let element = button.element;
|
||||
let props = {
|
||||
key: "button-" + index,
|
||||
onClick: (() => button.onClick(entry)),
|
||||
className: "data-table-clickable"
|
||||
}
|
||||
|
||||
if (typeof button.showIf !== 'function' || button.showIf(entry)) {
|
||||
buttonElements.push(React.createElement(element, props))
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
{buttonElements}
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ class Pagination {
|
||||
this.setData({...this.data, pageSize: pageSize});
|
||||
}
|
||||
|
||||
setTotal(count) {
|
||||
this.setData({...this.data, total: count});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setData({current: 1, pageSize: 25, total: 0});
|
||||
}
|
||||
@@ -37,6 +41,10 @@ class Pagination {
|
||||
}
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return [this.data.current, this.data.pageSize];
|
||||
}
|
||||
|
||||
getTotal() {
|
||||
return this.data.total;
|
||||
}
|
||||
|
||||
@@ -62,16 +62,6 @@ function LocaleProvider(props) {
|
||||
}
|
||||
}, [entries]);
|
||||
|
||||
const toDateFns = () => {
|
||||
switch (currentLocale) {
|
||||
case 'de_DE':
|
||||
return dateFnsDE;
|
||||
case 'en_US':
|
||||
default:
|
||||
return dateFnsEN;
|
||||
}
|
||||
}
|
||||
|
||||
/** API HOOKS **/
|
||||
const setLanguage = useCallback(async (api, params) => {
|
||||
let res = await api.setLanguage(params);
|
||||
@@ -96,8 +86,8 @@ function LocaleProvider(props) {
|
||||
|
||||
if (code === null) {
|
||||
code = currentLocale;
|
||||
if (code === null && api.loggedIn) {
|
||||
code = api.user.language.code;
|
||||
if (code === null && api.language) {
|
||||
code = api.language.code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +125,23 @@ function LocaleProvider(props) {
|
||||
}
|
||||
}, [currentLocale, getModule, dispatch]);
|
||||
|
||||
const toDateFns = useCallback(() => {
|
||||
switch (currentLocale) {
|
||||
case 'de_DE':
|
||||
return dateFnsDE;
|
||||
case 'en_US':
|
||||
default:
|
||||
return dateFnsEN;
|
||||
}
|
||||
}, [currentLocale]);
|
||||
|
||||
const ctx = {
|
||||
currentLocale: currentLocale,
|
||||
translate: translate,
|
||||
requestModules: requestModules,
|
||||
setLanguageByCode: setLanguageByCode,
|
||||
toDateFns: toDateFns,
|
||||
setCurrentLocale: setCurrentLocale,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,7 +59,7 @@ const formatDate = (L, apiDate) => {
|
||||
return format(apiDate, L("general.datefns_date_format", "YYY/MM/dd"));
|
||||
}
|
||||
|
||||
const formatDateTime = (L, apiDate) => {
|
||||
const formatDateTime = (L, apiDate, precise=false) => {
|
||||
if (!(apiDate instanceof Date)) {
|
||||
if (!isNaN(apiDate)) {
|
||||
apiDate = new Date(apiDate * 1000);
|
||||
@@ -68,7 +68,10 @@ const formatDateTime = (L, apiDate) => {
|
||||
}
|
||||
}
|
||||
|
||||
return format(apiDate, L("general.datefns_date_time_format", "YYY/MM/dd HH:mm:ss"));
|
||||
let dateFormat = precise ?
|
||||
L("general.datefns_date_time_format_precise", "YYY/MM/dd HH:mm:ss") :
|
||||
L("general.datefns_date_time_format", "YYY/MM/dd HH:mm");
|
||||
return format(apiDate, dateFormat);
|
||||
}
|
||||
|
||||
const upperFirstChars = (str) => {
|
||||
|
||||
@@ -71,15 +71,24 @@ export default function LoginForm(props) {
|
||||
|
||||
const api = props.api;
|
||||
const classes = useStyles();
|
||||
|
||||
// inputs
|
||||
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("");
|
||||
|
||||
// 2fa
|
||||
// 0: not sent, 1: sent, 2: retry
|
||||
let [tfaToken, set2FAToken] = useState(api.user?.twoFactorToken || { authenticated: false, type: null, step: 0 });
|
||||
let [error, setError] = useState("");
|
||||
|
||||
const abortController = new AbortController();
|
||||
const abortSignal = abortController.signal;
|
||||
|
||||
// state
|
||||
let [isLoggingIn, setLoggingIn] = useState(false);
|
||||
let [loaded, setLoaded] = useState(false);
|
||||
|
||||
const {translate: L, currentLocale, requestModules} = useContext(LocaleContext);
|
||||
@@ -103,13 +112,14 @@ export default function LoginForm(props) {
|
||||
setLoggingIn(true);
|
||||
removeParameter("success");
|
||||
api.login(username, password, rememberMe).then((res) => {
|
||||
set2FAState(0);
|
||||
let twoFactorToken = res.twoFactorToken || { };
|
||||
set2FAToken({ ...twoFactorToken, authenticated: false, step: 0, error: "" });
|
||||
setLoggingIn(false);
|
||||
setPassword("");
|
||||
if (!res.success) {
|
||||
setEmailConfirmed(res.emailConfirmed);
|
||||
setError(res.msg);
|
||||
} else {
|
||||
} else if (!twoFactorToken.type) {
|
||||
props.onLogin();
|
||||
}
|
||||
});
|
||||
@@ -118,111 +128,149 @@ export default function LoginForm(props) {
|
||||
|
||||
const onSubmit2FA = useCallback(() => {
|
||||
setLoggingIn(true);
|
||||
props.onTotp2FA(tfaCode, (res) => {
|
||||
api.verifyTotp2FA(tfaCode).then((res) => {
|
||||
setLoggingIn(false);
|
||||
if (res.success) {
|
||||
set2FAToken({ ...tfaToken, authenticated: true });
|
||||
props.onLogin();
|
||||
} else {
|
||||
set2FAToken({ ...tfaToken, step: 2, error: res.msg });
|
||||
}
|
||||
});
|
||||
}, [tfaCode, props]);
|
||||
}, [tfaCode, tfaToken, props]);
|
||||
|
||||
const onCancel2FA = useCallback(() => {
|
||||
abortController.abort();
|
||||
props.onLogout();
|
||||
}, [props]);
|
||||
set2FAToken({authenticated: false, step: 0, error: ""});
|
||||
}, [props, abortController]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api.loggedIn || !api.user) {
|
||||
if (!api.loggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
let twoFactor = api.user["2fa"];
|
||||
if (!twoFactor || !twoFactor.confirmed ||
|
||||
twoFactor.authenticated || twoFactor.type !== "fido") {
|
||||
if (!tfaToken || !tfaToken.confirmed || tfaToken.authenticated || tfaToken.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());
|
||||
let step = tfaToken.step || 0;
|
||||
if (step !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
set2FAToken({ ...tfaToken, step: 1, error: "" });
|
||||
navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: encodeText(window.atob(tfaToken.challenge)),
|
||||
allowCredentials: [{
|
||||
id: encodeText(window.atob(tfaToken.credentialID)),
|
||||
type: "public-key",
|
||||
}],
|
||||
userVerification: "discouraged",
|
||||
},
|
||||
signal: abortSignal
|
||||
}).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));
|
||||
api.verifyKey2FA(credentialID, clientDataJson, authData, signature).then((res) => {
|
||||
if (!res.success) {
|
||||
set2FAToken({ ...tfaToken, step: 2, error: res.msg });
|
||||
} else {
|
||||
props.onLogin();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [api.loggedIn, api.user, tfaState, props]);
|
||||
}).catch(e => {
|
||||
set2FAToken({ ...tfaToken, step: 2, error: e.toString() });
|
||||
});
|
||||
}, [api.loggedIn, tfaToken, props.onLogin, props.onKey2FA, abortSignal]);
|
||||
|
||||
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" ?
|
||||
if (api.loggedIn && tfaToken.type) {
|
||||
|
||||
if (tfaToken.type === "totp") {
|
||||
return <>
|
||||
<div>{L("account.2fa_title")}:</div>
|
||||
<TextField
|
||||
variant="outlined" margin="normal"
|
||||
id="code" label={L("account.6_digit_code")} name="code"
|
||||
autoComplete="code"
|
||||
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>
|
||||
/>
|
||||
{
|
||||
tfaToken.error ? <Alert severity="error">{tfaToken.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 || tfaToken.type !== "totp"}
|
||||
onClick={onSubmit2FA}>
|
||||
{isLoggingIn ?
|
||||
<>{L("general.submitting")}… <CircularProgress size={15}/></> :
|
||||
L("general.submit")
|
||||
}
|
||||
</Button>
|
||||
</Grid>
|
||||
</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>
|
||||
</>
|
||||
} else if (tfaToken.type === "fido") {
|
||||
return <>
|
||||
<div>{L("account.2fa_title")}:</div>
|
||||
<br />
|
||||
{L("account.2fa_text")}
|
||||
<Box mt={2} textAlign={"center"}>
|
||||
{tfaToken.step !== 2
|
||||
? <CircularProgress/>
|
||||
: <div className={classes.error2FA}>
|
||||
<div><b>{L("general.something_went_wrong")}:</b><br />{tfaToken.error}</div>
|
||||
<Button onClick={() => set2FAToken({ ...tfaToken, step: 0, error: "" })}
|
||||
variant={"outlined"} color={"secondary"} size={"small"}>
|
||||
<ReplayIcon /> {L("general.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</Box>
|
||||
<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 || tfaToken.type !== "totp"}
|
||||
onClick={onSubmit2FA}>
|
||||
{isLoggingIn ?
|
||||
<>{L("general.submitting")}… <CircularProgress size={15}/></> :
|
||||
L("general.submit")
|
||||
}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
|
||||
Reference in New Issue
Block a user