frontend, added user active flag, localization

This commit is contained in:
Roman 2024-03-30 11:22:59 +01:00
parent 9fc0a19f59
commit 0125c83bea
20 changed files with 220 additions and 92 deletions

@ -278,6 +278,7 @@ abstract class Request {
$this->success = $success; $this->success = $success;
} }
} catch (\Error $err) { } catch (\Error $err) {
http_response_code(500);
$this->createError($err->getMessage()); $this->createError($err->getMessage());
$this->logger->error($err->getMessage()); $this->logger->error($err->getMessage());
} }

@ -57,6 +57,7 @@ class Stats extends Request {
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
$userCount = User::count($sql); $userCount = User::count($sql);
$pageCount = Route::count($sql, new CondBool("active")); $pageCount = Route::count($sql, new CondBool("active"));
$groupCount = Group::count($sql);
$req = new \Core\API\Visitors\Stats($this->context); $req = new \Core\API\Visitors\Stats($this->context);
$this->success = $req->execute(array("type"=>"monthly")); $this->success = $req->execute(array("type"=>"monthly"));
$this->lastError = $req->getLastError(); $this->lastError = $req->getLastError();
@ -82,6 +83,7 @@ class Stats extends Request {
$this->result["data"] = [ $this->result["data"] = [
"userCount" => $userCount, "userCount" => $userCount,
"pageCount" => $pageCount, "pageCount" => $pageCount,
"groupCount" => $groupCount,
"visitors" => $visitorStatistics, "visitors" => $visitorStatistics,
"visitorsTotal" => $visitorCount, "visitorsTotal" => $visitorCount,
"server" => [ "server" => [

@ -576,8 +576,9 @@ namespace Core\API\User {
if ($user !== false) { if ($user !== false) {
if ($user === null) { if ($user === null) {
return $this->wrongCredentials(); return $this->wrongCredentials();
} else { } else if (!$user->isActive()) {
if (password_verify($password, $user->password)) { return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake.");
} else if (password_verify($password, $user->password)) {
if (!$user->confirmed) { if (!$user->confirmed) {
$this->result["emailConfirmed"] = false; $this->result["emailConfirmed"] = false;
return $this->createError("Your email address has not been confirmed yet."); return $this->createError("Your email address has not been confirmed yet.");
@ -596,7 +597,6 @@ namespace Core\API\User {
} else { } else {
return $this->wrongCredentials(); return $this->wrongCredentials();
} }
}
} else { } else {
return $this->createError("Error fetching user details: " . $sql->getLastError()); return $this->createError("Error fetching user details: " . $sql->getLastError());
} }
@ -934,6 +934,9 @@ namespace Core\API\User {
if ($user === false) { if ($user === false) {
return $this->createError("Could not fetch user details: " . $sql->getLastError()); return $this->createError("Could not fetch user details: " . $sql->getLastError());
} else if ($user !== null) { } else if ($user !== null) {
if (!$user->isActive()) {
return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake.");
} else {
$validHours = 1; $validHours = 1;
$token = generateRandomString(36); $token = generateRandomString(36);
$userToken = new UserToken($user, $token, UserToken::TYPE_PASSWORD_RESET, $validHours); $userToken = new UserToken($user, $token, UserToken::TYPE_PASSWORD_RESET, $validHours);
@ -973,6 +976,7 @@ namespace Core\API\User {
$this->logger->info("Requested password reset for user id=" . $user->getId() . " by ip_address=" . $_SERVER["REMOTE_ADDR"]); $this->logger->info("Requested password reset for user id=" . $user->getId() . " by ip_address=" . $_SERVER["REMOTE_ADDR"]);
} }
} }
}
return $this->success; return $this->success;
} }

@ -22,6 +22,7 @@ return [
"reset_password_form_title" => "Ein neues Passwort wählen", "reset_password_form_title" => "Ein neues Passwort wählen",
"reset_password_request_form_title" => "Geben Sie Ihre E-Mail Adresse ein um ein Passwort-Reset Token zu erhalten", "reset_password_request_form_title" => "Geben Sie Ihre E-Mail Adresse ein um ein Passwort-Reset Token zu erhalten",
"form_title" => "Bitte geben Sie ihre Daten ein", "form_title" => "Bitte geben Sie ihre Daten ein",
"name" => "Name",
"username" => "Benutzername", "username" => "Benutzername",
"username_or_email" => "Benutzername oder E-Mail", "username_or_email" => "Benutzername oder E-Mail",
"email" => "E-Mail Adresse", "email" => "E-Mail Adresse",
@ -60,5 +61,11 @@ return [
"registered_at" => "Registriert am", "registered_at" => "Registriert am",
"last_online" => "Zuletzt online", "last_online" => "Zuletzt online",
"groups" => "Gruppen", "groups" => "Gruppen",
"group_name" => "Gruppenname",
"new_group" => "Neue Gruppe",
"members" => "Mitglieder",
"member_count" => "Mitgliederanzahl",
"color" => "Farbe",
"logged_in_as" => "Eingeloggt als", "logged_in_as" => "Eingeloggt als",
"active" => "Aktiv",
]; ];

@ -12,4 +12,12 @@ return [
"acl" => "Zugriffsberechtigung", "acl" => "Zugriffsberechtigung",
"logs" => "Logs", "logs" => "Logs",
"help" => "Hilfe", "help" => "Hilfe",
# Dashboard
"users_registered" => "Benutzer registriert",
"available_groups" => "verfügbare Gruppen",
"routes_defined" => "Routen definiert",
# Dialogs
"fetch_stats_error" => "Fehler beim Holen der Stats",
]; ];

@ -10,6 +10,8 @@ return [
"search_query" => "Suchanfrage", "search_query" => "Suchanfrage",
"no_entries_placeholder" => "Keine Log-Einträge zum Anzeigen", "no_entries_placeholder" => "Keine Log-Einträge zum Anzeigen",
"timestamp_placeholder" => "Datum und Zeitpunk Auswählen zum Filtern", "timestamp_placeholder" => "Datum und Zeitpunk Auswählen zum Filtern",
"hide_details" => "Details verstecken",
"show_details" => "Details zeigen",
// dialog // dialog
"fetch_log_error" => "Fehler beim Holen der Log-Einträge", "fetch_log_error" => "Fehler beim Holen der Log-Einträge",

@ -22,6 +22,7 @@ return [
"reset_password_form_title" => "Choose a new password", "reset_password_form_title" => "Choose a new password",
"reset_password_request_form_title" => "Enter your E-Mail address, to receive a password reset token.", "reset_password_request_form_title" => "Enter your E-Mail address, to receive a password reset token.",
"form_title" => "Please fill with your details", "form_title" => "Please fill with your details",
"name" => "Name",
"username" => "Username", "username" => "Username",
"username_or_email" => "Username or E-Mail", "username_or_email" => "Username or E-Mail",
"email" => "E-Mail Address", "email" => "E-Mail Address",
@ -60,5 +61,11 @@ return [
"registered_at" => "Registered At", "registered_at" => "Registered At",
"last_online" => "Last Online", "last_online" => "Last Online",
"groups" => "Groups", "groups" => "Groups",
"group_name" => "Group Name",
"new_group" => "New Group",
"members" => "Members",
"member_count" => "Member Count",
"color" => "Color",
"logged_in_as" => "Logged in as", "logged_in_as" => "Logged in as",
"active" => "Active",
]; ];

@ -12,4 +12,12 @@ return [
"acl" => "Access Control", "acl" => "Access Control",
"logs" => "Logs", "logs" => "Logs",
"help" => "Help", "help" => "Help",
# Dashboard
"users_registered" => "Users registered",
"available_groups" => "available Groups",
"routes_defined" => "Routes defined",
# Dialogs
"fetch_stats_error" => "Error fetching stats",
]; ];

@ -59,7 +59,6 @@ return [
"move" => "Move", "move" => "Move",
"overwrite" => "Overwrite", "overwrite" => "Overwrite",
# data table # data table
"showing_x_of_y_entries" => "Showing %d of %d entries", "showing_x_of_y_entries" => "Showing %d of %d entries",
"controls" => "Controls", "controls" => "Controls",

@ -10,6 +10,8 @@ return [
"search_query" => "Search query", "search_query" => "Search query",
"no_entries_placeholder" => "No log entries to display", "no_entries_placeholder" => "No log entries to display",
"timestamp_placeholder" => "Select date and time to filter", "timestamp_placeholder" => "Select date and time to filter",
"hide_details" => "Hide details",
"show_details" => "Show details",
// dialog // dialog
"fetch_log_error" => "Error fetching log entries", "fetch_log_error" => "Error fetching log entries",

@ -176,8 +176,9 @@ class Context {
->addJoin(new InnerJoin("ApiKey", "ApiKey.user_id", "User.id")) ->addJoin(new InnerJoin("ApiKey", "ApiKey.user_id", "User.id"))
->whereEq("ApiKey.api_key", $apiKey) ->whereEq("ApiKey.api_key", $apiKey)
->whereGt("valid_until", $this->sql->currentTimestamp()) ->whereGt("valid_until", $this->sql->currentTimestamp())
->whereTrue("ApiKey.active", true) ->whereTrue("ApiKey.active")
->whereTrue("User.confirmed", true) ->whereTrue("User.confirmed")
->whereTrue("User.active")
->fetchEntities()); ->fetchEntities());
return $this->user !== null; return $this->user !== null;

@ -46,10 +46,16 @@ class Session extends DatabaseEntity {
->whereEq("Session.uuid", $sessionUUID) ->whereEq("Session.uuid", $sessionUUID)
->whereTrue("Session.active") ->whereTrue("Session.active")
->whereGt("Session.expires", $sql->now())); ->whereGt("Session.expires", $sql->now()));
if (!$session) { if (!$session) {
return null; return null;
} }
$user = $session->getUser();
if (!$user->isActive() || !$user->isConfirmed()) {
return null;
}
if (is_array($session->data)) { if (is_array($session->data)) {
foreach ($session->data as $key => $value) { foreach ($session->data as $key => $value) {
$_SESSION[$key] = $value; $_SESSION[$key] = $value;

@ -45,6 +45,10 @@ class User extends DatabaseEntity {
#[DefaultValue(false)] #[DefaultValue(false)]
public bool $confirmed; public bool $confirmed;
#[Visibility(Visibility::BY_GROUP, Group::ADMIN, Group::SUPPORT)]
#[DefaultValue(true)]
public bool $active;
#[DefaultValue(Language::AMERICAN_ENGLISH)] public Language $language; #[DefaultValue(Language::AMERICAN_ENGLISH)] public Language $language;
#[Visibility(Visibility::BY_GROUP, Group::ADMIN, Group::SUPPORT)] #[Visibility(Visibility::BY_GROUP, Group::ADMIN, Group::SUPPORT)]
@ -92,6 +96,14 @@ class User extends DatabaseEntity {
return $this->profilePicture; return $this->profilePicture;
} }
public function isActive():bool {
return $this->active;
}
public function isConfirmed():bool {
return $this->confirmed;
}
public function __debugInfo(): array { public function __debugInfo(): array {
return [ return [
'id' => $this->getId(), 'id' => $this->getId(),

@ -65,7 +65,13 @@ if ($installation) {
is_string($_GET["error"]) && preg_match("/^\d+$/", $_GET["error"])) { is_string($_GET["error"]) && preg_match("/^\d+$/", $_GET["error"])) {
$response = $router->returnStatusCode(intval($_GET["error"])); $response = $router->returnStatusCode(intval($_GET["error"]));
} else { } else {
try {
$response = $router->run($requestedUri); $response = $router->run($requestedUri);
} catch (\Error $e) {
http_response_code(500);
$router->getLogger()->error($e->getMessage());
$router->returnStatusCode(500);
}
} }
} }

@ -56,7 +56,7 @@ export default function EditGroupView(props) {
<div className={"col-sm-6"}> <div className={"col-sm-6"}>
<ol className={"breadcrumb float-sm-right"}> <ol className={"breadcrumb float-sm-right"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> <li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active"><Link to={"/admin/groups"}>Group</Link></li> <li className="breadcrumb-item active"><Link to={"/admin/groups"}>{L("account.group")}</Link></li>
<li className="breadcrumb-item active">{ isNewGroup ? L("general.new") : groupId }</li> <li className="breadcrumb-item active">{ isNewGroup ? L("general.new") : groupId }</li>
</ol> </ol>
</div> </div>
@ -68,13 +68,15 @@ export default function EditGroupView(props) {
<div className={"col-6 pl-5 pr-5"}> <div className={"col-6 pl-5 pr-5"}>
<form role={"form"} onSubmit={(e) => this.submitForm(e)}> <form role={"form"} onSubmit={(e) => this.submitForm(e)}>
<div className={"form-group"}> <div className={"form-group"}>
<label htmlFor={"name"}>Group Name</label> <label htmlFor={"name"}>{L("account.group_name")}</label>
<input type={"text"} className={"form-control"} placeholder={"Name"} <input type={"text"} className={"form-control"} placeholder={"Name"}
name={"name"} id={"name"} maxLength={32} value={group.name}/> name={"name"} id={"name"} maxLength={32} value={group.name}/>
</div> </div>
<div className={"form-group"}> <div className={"form-group"}>
<label htmlFor={"color"}>Color</label> <label htmlFor={"color"}>
{L("account.color")}
</label>
<div> <div>
<ColorPicker <ColorPicker
value={group.color} value={group.color}
@ -88,13 +90,15 @@ export default function EditGroupView(props) {
</div> </div>
<Link to={"/admin/groups"} className={"btn btn-info mt-2 mr-2"}> <Link to={"/admin/groups"} className={"btn btn-info mt-2 mr-2"}>
&nbsp;Back &nbsp;{L("general.go_back")}
</Link> </Link>
<button type={"submit"} className={"btn btn-primary mt-2"}>Submit</button> <button type={"submit"} className={"btn btn-primary mt-2"}>
{L("general.submit")}
</button>
</form> </form>
</div> </div>
<div className={"col-6"}> <div className={"col-6"}>
<h3>Members</h3> <h3>{L("account.members")}</h3>
</div> </div>
</div> </div>
</div> </div>

@ -52,8 +52,8 @@ export default function GroupListView(props) {
const columnDefinitions = [ const columnDefinitions = [
new NumericColumn(L("general.id"), "id"), new NumericColumn(L("general.id"), "id"),
new StringColumn(L("group.name"), "name"), new StringColumn(L("account.name"), "name"),
new NumericColumn(L("group.member_count"), "memberCount"), new NumericColumn(L("account.member_count"), "memberCount"),
actionColumn, actionColumn,
]; ];
@ -66,7 +66,7 @@ export default function GroupListView(props) {
<div className={"col-sm-6"}> <div className={"col-sm-6"}>
<ol className={"breadcrumb float-sm-right"}> <ol className={"breadcrumb float-sm-right"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> <li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active">Groups</li> <li className="breadcrumb-item active">{L("account.groups")}</li>
</ol> </ol>
</div> </div>
</div> </div>

@ -9,7 +9,8 @@ import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import {API_DATETIME_FORMAT} from "shared/constants"; import {API_DATETIME_FORMAT} from "shared/constants";
import {format, toDate} from "date-fns"; import {format, toDate} from "date-fns";
import {FormControl, FormGroup, FormLabel, MenuItem, Select} from "@material-ui/core"; import {Box, FormControl, FormGroup, FormLabel, IconButton, MenuItem, Select} from "@material-ui/core";
import {ExpandLess, ExpandMore} from "@material-ui/icons";
export default function LogView(props) { export default function LogView(props) {
@ -59,6 +60,16 @@ export default function LogView(props) {
}); });
}, [api, showDialog, logLevel, timestamp, query]); }, [api, showDialog, logLevel, timestamp, query]);
const onToggleDetails = useCallback(entry => {
let newLogEntries = [...logEntries];
for (const logEntry of newLogEntries) {
if (logEntry.id === entry.id) {
logEntry.showDetails = !logEntry.showDetails;
}
}
setLogEntries(newLogEntries);
}, [logEntries]);
useEffect(() => { useEffect(() => {
// TODO: wait for user to finish typing before force reloading // TODO: wait for user to finish typing before force reloading
setForceReload(forceReload + 1); setForceReload(forceReload + 1);
@ -68,7 +79,19 @@ export default function LogView(props) {
let column = new DataColumn(L("logs.message"), "message"); let column = new DataColumn(L("logs.message"), "message");
column.sortable = false; column.sortable = false;
column.renderData = (L, entry) => { column.renderData = (L, entry) => {
return <pre>{entry.message}</pre> return <Box display={"grid"} gridTemplateColumns={"40px auto"}>
<Box alignSelf={"top"} textAlign={"center"}>
<IconButton size={"small"} onClick={() => onToggleDetails(entry)}
title={L(entry.showDetails ? "logs.hide_details" : "logs.show_details")}>
{entry.showDetails ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Box>
<Box alignSelf={"center"}>
<pre>
{entry.showDetails ? entry.message : entry.message.split("\n")[0]}
</pre>
</Box>
</Box>
} }
return column; return column;
})(); })();

@ -1,13 +1,45 @@
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 {CircularProgress, Icon} from "@material-ui/core"; import {CircularProgress} from "@material-ui/core";
import {useCallback, useEffect, useState} from "react"; import {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale";
import {LibraryBooks, People} from "@material-ui/icons";
import {ArrowCircleRight, Groups} from "@mui/icons-material";
const StatBox = (props) => <div className={"col-lg-3 col-6"}>
<div className={"small-box bg-" + props.color}>
<div className={"inner"}>
{props.count ?
<>
<h3>{props.count}</h3>
<p>{props.text}</p>
</> : <CircularProgress variant={"determinate"} />
}
</div>
<div className={"icon"}>
{props.icon}
</div>
<Link to={props.link} className={"small-box-footer text-right p-1"}>
More info <ArrowCircleRight />
</Link>
</div>
</div>
export default function Overview(props) { export default function Overview(props) {
const [fetchStats, setFetchStats] = useState(true); const [fetchStats, setFetchStats] = useState(true);
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const {translate: L, currentLocale, requestModules} = useContext(LocaleContext);
useEffect(() => {
requestModules(props.api, ["general", "admin"], currentLocale).then(data => {
if (!data.success) {
props.showDialog("Error fetching translations: " + data.msg);
}
});
}, [currentLocale]);
const onFetchStats = useCallback((force = false) => { const onFetchStats = useCallback((force = false) => {
if (force || fetchStats) { if (force || fetchStats) {
@ -16,7 +48,7 @@ export default function Overview(props) {
if (res.success) { if (res.success) {
setStats(res.data); setStats(res.data);
} else { } else {
props.showDialog("Error fetching stats: " + res.msg, "Error fetching stats"); props.showDialog(res.msg, L("admin.fetch_stats_error"));
} }
}); });
} }
@ -73,12 +105,12 @@ export default function Overview(props) {
<div className={"container-fluid"}> <div className={"container-fluid"}>
<div className={"row mb-2"}> <div className={"row mb-2"}>
<div className={"col-sm-6"}> <div className={"col-sm-6"}>
<h1 className={"m-0 text-dark"}>Dashboard</h1> <h1 className={"m-0 text-dark"}>{L("admin.dashboard")}</h1>
</div> </div>
<div className={"col-sm-6"}> <div className={"col-sm-6"}>
<ol className={"breadcrumb float-sm-right"}> <ol className={"breadcrumb float-sm-right"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li> <li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active">Dashboard</li> <li className="breadcrumb-item active">{L("admin.dashboard")}</li>
</ol> </ol>
</div> </div>
</div> </div>
@ -87,24 +119,18 @@ export default function Overview(props) {
<section className={"content"}> <section className={"content"}>
<div className={"container-fluid"}> <div className={"container-fluid"}>
<div className={"row"}> <div className={"row"}>
<div className={"col-lg-3 col-6"}> <StatBox color={"info"} count={stats?.userCount}
<div className="small-box bg-info"> text={L("admin.users_registered")}
<div className={"inner"}> icon={<People />}
{stats ? link={"/admin/users"} />
<> <StatBox color={"success"} count={stats?.groupCount}
<h3>{stats.userCount}</h3> text={L("admin.available_groups")}
<p>Users registered</p> icon={<Groups />}
</> : <CircularProgress variant={"determinate"} /> link={"/admin/users"} />
} <StatBox color={"warning"} count={stats?.pageCount}
</div> text={L("admin.routes_defined")}
<div className="icon"> icon={<LibraryBooks />}
<Icon icon={"users"} /> link={"/admin/routes"} />
</div>
<Link to={"/admin/users"} className="small-box-footer">
More info <Icon icon={"arrow-circle-right"}/>
</Link>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>

@ -5,7 +5,7 @@ import {
BoolColumn, BoolColumn,
ControlsColumn, ControlsColumn,
DataColumn, DataColumn,
DataTable, DataTable, DateTimeColumn,
NumericColumn, NumericColumn,
StringColumn StringColumn
} from "shared/elements/data-table"; } from "shared/elements/data-table";
@ -48,7 +48,11 @@ 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 = (L, 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}
style={{ backgroundColor: group.color }}
label={group.name} />
)
} }
return column; return column;
})(); })();
@ -59,6 +63,8 @@ export default function UserListView(props) {
new StringColumn(L("account.full_name"), "fullName"), new StringColumn(L("account.full_name"), "fullName"),
new StringColumn(L("account.email"), "email"), new StringColumn(L("account.email"), "email"),
groupColumn, groupColumn,
new DateTimeColumn(L("account.registered_at"), "registeredAt"),
new BoolColumn(L("account.active"), "active", { align: "center" }),
new BoolColumn(L("account.confirmed"), "confirmed", { align: "center" }), new BoolColumn(L("account.confirmed"), "confirmed", { align: "center" }),
new ControlsColumn(L("general.controls"), [ new ControlsColumn(L("general.controls"), [
{ label: L("general.edit"), element: EditIcon, onClick: (entry) => navigate(`/admin/user/${entry.id}`) } { label: L("general.edit"), element: EditIcon, onClick: (entry) => navigate(`/admin/user/${entry.id}`) }

@ -83,7 +83,11 @@ export function DataTable(props) {
title={L("general.sort_by") + ": " + column.label} title={L("general.sort_by") + ": " + column.label}
onClick={() => onChangeSort(index, column)} onClick={() => onChangeSort(index, column)}
align={column.align}> align={column.align}>
{sortColumn === index ? (sortAscending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />): <></>}{column.renderHead(index)} {sortColumn === index ?
(sortAscending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />) :
<></>
}
{column.renderHead(index)}
</TableCell>); </TableCell>);
} else { } else {
headerRow.push(<TableCell key={"col-" + index} align={column.align}> headerRow.push(<TableCell key={"col-" + index} align={column.align}>