Composer update, Groups frontend, API improvements

This commit is contained in:
Roman 2024-04-02 12:54:05 +02:00
parent 0125c83bea
commit 1e33c3b8d4
12 changed files with 699 additions and 471 deletions

@ -4,6 +4,8 @@ namespace Core\API {
use Core\Driver\SQL\Expression\Count;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\User;
abstract class GroupsAPI extends Request {
@ -22,6 +24,30 @@ namespace Core\API {
$this->lastError = $sql->getLastError();
return $this->success && $res[0]["count"] > 0;
}
protected function getGroup(int $groupId): Group|false {
$sql = $this->context->getSQL();
$group = Group::find($sql, $groupId);
if ($group === false) {
return $this->createError("Error fetching group: " . $sql->getLastError());
} else if ($group === null) {
return $this->createError("This group does not exist.");
}
return $group;
}
protected function getUser(int $userId): User|false {
$sql = $this->context->getSQL();
$user = User::find($sql, $userId, true);
if ($user === false) {
return $this->createError("Error fetching user: " . $sql->getLastError());
} else if ($user === null) {
return $this->createError("This user does not exist.");
}
return $user;
}
}
}
@ -33,12 +59,14 @@ namespace Core\API\Groups {
use Core\API\Traits\Pagination;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondAnd;
use Core\Driver\SQL\Expression\Alias;
use Core\Driver\SQL\Expression\Count;
use Core\Driver\SQL\Join\InnerJoin;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Route;
use Core\Objects\DatabaseEntity\User;
class Fetch extends GroupsAPI {
@ -49,7 +77,7 @@ namespace Core\API\Groups {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall,
self::getPaginationParameters(['id', 'name', 'member_count'])
self::getPaginationParameters(['id', 'name', 'memberCount'])
);
$this->groupCount = 0;
@ -97,14 +125,9 @@ namespace Core\API\Groups {
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$groupId = $this->getParam("id");
$group = Group::find($sql, $groupId);
if ($group === false) {
return $this->createError("Error fetching group: " . $sql->getLastError());
} else if ($group === null) {
return $this->createError("Group not found");
} else {
$group = $this->getGroup($groupId);
if ($group) {
$this->result["group"] = $group->jsonSerialize();
}
@ -157,10 +180,10 @@ namespace Core\API\Groups {
class Create extends GroupsAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
parent::__construct($context, $externalCall, [
'name' => new StringType('name', 32),
'color' => new StringType('color', 10),
));
]);
}
public function _execute(): bool {
@ -199,6 +222,55 @@ namespace Core\API\Groups {
}
}
class Update extends GroupsAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, [
"id" => new Parameter("id", Parameter::TYPE_INT),
'name' => new StringType('name', 32),
'color' => new StringType('color', 10),
]);
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$groupId = $this->getParam("id");
$name = $this->getParam("name");
if (preg_match("/^[a-zA-Z][a-zA-Z0-9_-]*$/", $name) !== 1) {
return $this->createError("Invalid name");
}
$color = $this->getParam("color");
if (preg_match("/^#[a-fA-F0-9]{3,6}$/", $color) !== 1) {
return $this->createError("Invalid color");
}
$group = $this->getGroup($groupId);
if ($group === false) {
return false;
}
$otherGroup = Group::findBy(Group::createBuilder($sql, true)
->whereNeq("id", $groupId)
->whereEq("name", $name)
->first());
if ($otherGroup) {
return $this->createError("This name is already in use");
}
$group->name = $name;
$group->color = $color;
$this->success = ($group->save($sql) !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success;
}
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to update existing groups", true);
}
}
class Delete extends GroupsAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
@ -213,16 +285,13 @@ namespace Core\API\Groups {
}
$sql = $this->context->getSQL();
$group = Group::find($sql, $id);
if ($group === false) {
return $this->createError("Error fetching group: " . $sql->getLastError());
} else if ($group === null) {
return $this->createError("This group does not exist.");
} else {
$group = $this->getGroup($id);
if ($group) {
$this->success = ($group->delete($sql) !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success;
}
return $this->success;
}
public static function getDefaultACL(Insert $insert): void {
@ -233,32 +302,31 @@ namespace Core\API\Groups {
class AddMember extends GroupsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
new Parameter("id", Parameter::TYPE_INT),
new Parameter("userId", Parameter::TYPE_INT)
"id" => new Parameter("id", Parameter::TYPE_INT),
"userId" => new Parameter("userId", Parameter::TYPE_INT)
]);
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$groupId = $this->getParam("id");
$userId = $this->getParam("userId");
$group = Group::find($sql, $groupId);
$group = $this->getGroup($groupId);
if ($group === false) {
return $this->createError("Error fetching group: " . $sql->getLastError());
} else if ($group === null) {
return $this->createError("This group does not exist.");
return false;
}
$user = User::find($sql, $userId, true);
$userId = $this->getParam("userId");
$currentUser = $this->context->getUser();
$user = $this->getUser($userId);
if ($user === false) {
return $this->createError("Error fetching user: " . $sql->getLastError());
} else if ($user === null) {
return $this->createError("This user does not exist.");
return false;
} else if (isset($user->getGroups()[$groupId])) {
return $this->createError("This user is already member of this group.");
} else if ($groupId === Group::ADMIN && !$currentUser->hasGroup(Group::ADMIN)) {
return $this->createError("You cannot add the administrator group to other users.");
}
$user->groups[$groupId] = $group;
$sql = $this->context->getSQL();
$this->success = $user->save($sql, ["groups"], true);
if (!$this->success) {
return $this->createError("Error saving user: " . $sql->getLastError());
@ -275,32 +343,31 @@ namespace Core\API\Groups {
class RemoveMember extends GroupsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
new Parameter("id", Parameter::TYPE_INT),
new Parameter("userId", Parameter::TYPE_INT)
"id" => new Parameter("id", Parameter::TYPE_INT),
"userId" => new Parameter("userId", Parameter::TYPE_INT)
]);
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$groupId = $this->getParam("id");
$userId = $this->getParam("userId");
$group = Group::find($sql, $groupId);
$group = $this->getGroup($groupId);
if ($group === false) {
return $this->createError("Error fetching group: " . $sql->getLastError());
} else if ($group === null) {
return $this->createError("This group does not exist.");
return false;
}
$user = User::find($sql, $userId, true);
$userId = $this->getParam("userId");
$currentUser = $this->context->getUser();
$user = $this->getUser($userId);
if ($user === false) {
return $this->createError("Error fetching user: " . $sql->getLastError());
} else if ($user === null) {
return $this->createError("This user does not exist.");
return false;
} else if (!isset($user->getGroups()[$groupId])) {
return $this->createError("This user is not member of this group.");
} else if ($userId === $currentUser->getId() && $groupId === Group::ADMIN) {
return $this->createError("Cannot remove Administrator group from own user.");
}
unset($user->groups[$groupId]);
$sql = $this->context->getSQL();
$this->success = $user->save($sql, ["groups"], true);
if (!$this->success) {
return $this->createError("Error saving user: " . $sql->getLastError());

@ -88,13 +88,12 @@ namespace Core\API\Language {
return $this->success;
}
private function updateLanguage(): bool {
private function updateLanguage(): void {
$sql = $this->context->getSQL();
$currentUser = $this->context->getUser();
$currentUser->language = $this->language;
$this->success = $currentUser->save($sql, ["language"]);
$this->lastError = $sql->getLastError();
return $this->success;
}
public function _execute(): bool {
@ -141,12 +140,12 @@ namespace Core\API\Language {
}
$moduleFound = false;
foreach (["Site", "Core"] as $baseDir) {
$filePath = realpath(implode("/", [$baseDir, "Localization", $code, "$module.php"]));
foreach (["Core", "Site"] as $baseDir) {
$filePath = realpath(implode("/", [WEBROOT, $baseDir, "Localization", $code, "$module.php"]));
if ($filePath && is_file($filePath)) {
$moduleFound = true;
$moduleEntries = @include_once $filePath;
$entries[$module] = $moduleEntries;
$entries[$module] = array_merge($entries[$module] ?? [], $moduleEntries);
break;
}
}

@ -106,8 +106,11 @@ class Swagger extends Request {
"post" => [
"produces" => ["application/json"],
"responses" => [
"200" => ["description" => ""],
"200" => ["description" => "OK!"],
"400" => ["description" => "Parameter validation failed"],
"401" => ["description" => "Login or 2FA Authorization is required"],
"403" => ["description" => "CSRF-Token validation failed or insufficient permissions"],
"503" => ["description" => "Function is disabled"],
]
]
];

@ -136,6 +136,7 @@ namespace Core\API\User {
use Core\API\UserAPI;
use Core\API\VerifyCaptcha;
use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Condition\CondLike;
use Core\Driver\SQL\Condition\CondOr;
use Core\Driver\SQL\Expression\Alias;
use Core\Driver\SQL\Query\Insert;
@ -320,6 +321,39 @@ namespace Core\API\User {
}
}
class Search extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"query" => new StringType("query", 64)
]);
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$query = $this->getParam("query");
$users = User::findBy(User::createBuilder($sql, false)
->where(new CondOr(
new CondLike(new Column("name"), "%$query%"),
new CondLike(new Column("full_name"), "%$query%"),
new CondLike(new Column("email"), "%$query%"),
))
->whereTrue("active")
);
if ($users === false) {
return $this->createError($sql->getLastError());
}
$this->result["users"] = $users;
return true;
}
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), "Allows users to search other users", [Group::ADMIN, Group::SUPPORT], true);
}
}
class Info extends UserAPI {
public function __construct(Context $context, $externalCall = false) {

@ -12,5 +12,6 @@ class Admin extends TemplateDocument {
$this->searchable = false;
$this->enableCSP();
$this->addCSPWhitelist("/react/dist/admin-panel/");
$this->languageModules[] = "admin";
}
}

@ -1,14 +1,14 @@
{
"require": {
"php-mqtt/client": "^1.1",
"twig/twig": "^3.0",
"chillerlan/php-qrcode": "^4.3",
"php-mqtt/client": "^2.0",
"twig/twig": "^3.8",
"chillerlan/php-qrcode": "^5.0",
"christian-riesen/base32": "^1.6",
"spomky-labs/cbor-php": "2.1.0",
"web-auth/cose-lib": "3.3.12",
"spomky-labs/cbor-php": "^3.0",
"web-auth/cose-lib": "^4.0",
"html2text/html2text": "^4.3"
},
"require-dev": {
"phpunit/phpunit": "^9.5"
"phpunit/phpunit": "^9.6"
}
}

691
Core/External/composer.lock generated vendored

File diff suppressed because it is too large Load Diff

@ -1,7 +1,7 @@
{% extends "base.twig" %}
{% block head %}
<title>{{ site.name }} - {{ L("admin.admin") }}</title>
<title>{{ site.name }} - {{ L("admin.title") }}</title>
<link rel="stylesheet" href="/css/fontawesome.min.css" nonce="{{ site.csp.nonce }}">
{% endblock %}

@ -397,7 +397,7 @@ zend.exception_string_param_max_len = 0
; threat in any way, but it makes it possible to determine whether you use PHP
; on your server or not.
; https://php.net/expose-php
expose_php = On
expose_php = Off
;;;;;;;;;;;;;;;;;;;
; Resource Limits ;

@ -1,9 +1,15 @@
import {useCallback, useContext, useEffect, useState} from "react";
import {Link, useNavigate, useParams} from "react-router-dom";
import {LocaleContext} from "shared/locale";
import {CircularProgress} from "@material-ui/core";
import {Button, CircularProgress} from "@material-ui/core";
import * as React from "react";
import ColorPicker from "material-ui-color-picker";
import {ControlsColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
import EditIcon from "@mui/icons-material/Edit";
import usePagination from "shared/hooks/pagination";
import {Delete, KeyboardArrowLeft, Save} from "@material-ui/icons";
import Dialog from "shared/elements/dialog";
import {Box, FormControl, FormGroup, FormLabel, styled, TextField} from "@mui/material";
const defaultGroupData = {
name: "",
@ -11,6 +17,12 @@ const defaultGroupData = {
members: []
};
const ButtonBar = styled(Box)((props) => ({
"& > button": {
marginRight: props.theme.spacing(1)
}
}));
export default function EditGroupView(props) {
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);
@ -18,14 +30,30 @@ export default function EditGroupView(props) {
const { groupId } = useParams();
const navigate = useNavigate();
const isNewGroup = groupId === "new";
const pagination = usePagination();
const api = props.api;
// data
const [fetchGroup, setFetchGroup] = useState(!isNewGroup);
const [group, setGroup] = useState(isNewGroup ? defaultGroupData : null);
const [members, setMembers] = useState([]);
// ui
const [dialogData, setDialogData] = useState({open: false});
const [isSaving, setSaving] = useState(false);
useEffect(() => {
requestModules(props.api, ["general", "account"], currentLocale).then(data => {
if (!data.success) {
props.showDialog(data.msg, "Error fetching localization");
}
});
}, [currentLocale]);
const onFetchGroup = useCallback((force = false) => {
if (force || fetchGroup) {
setFetchGroup(false);
props.api.getGroup(groupId).then(res => {
api.getGroup(groupId).then(res => {
if (!res.success) {
props.showDialog(res.msg, "Error fetching group");
navigate("/admin/groups");
@ -34,7 +62,51 @@ export default function EditGroupView(props) {
}
});
}
}, []);
}, [api, fetchGroup]);
const onFetchMembers = useCallback(async (page, count, orderBy, sortOrder) => {
api.fetchGroupMembers(groupId, page, count, orderBy, sortOrder).then((res) => {
if (res.success) {
setMembers(res.users);
pagination.update(res.pagination);
} else {
props.showDialog(res.msg, "Error fetching group members");
return null;
}
});
}, [groupId, api, pagination]);
const onRemoveMember = useCallback(userId => {
api.removeGroupMember(groupId, userId).then(data => {
if (data.success) {
let newMembers = members.filter(u => u.id !== userId);
setMembers(newMembers);
} else {
props.showDialog(data.msg, "Error removing group member");
}
});
}, [api, groupId, members]);
const onSave = useCallback(() => {
setSaving(true);
if (isNewGroup) {
api.createGroup(group.name, group.color).then(data => {
if (!data.success) {
props.showDialog(data.msg, "Error creating group");
setSaving(false);
} else {
navigate(`/admin/groups/${data.id}`)
}
});
} else {
api.updateGroup(groupId, group.name, group.color).then(data => {
setSaving(false);
if (!data.success) {
props.showDialog(data.msg, "Error updating group");
}
});
}
}, [api, groupId, isNewGroup, group]);
useEffect(() => {
onFetchGroup();
@ -65,43 +137,97 @@ export default function EditGroupView(props) {
</div>
<div className={"content"}>
<div className={"row"}>
<div className={"col-6 pl-5 pr-5"}>
<form role={"form"} onSubmit={(e) => this.submitForm(e)}>
<div className={"form-group"}>
<label htmlFor={"name"}>{L("account.group_name")}</label>
<input type={"text"} className={"form-control"} placeholder={"Name"}
name={"name"} id={"name"} maxLength={32} value={group.name}/>
</div>
<div className={"col-4 pl-5 pr-5"}>
<FormGroup className={"my-2"}>
<FormLabel htmlFor={"name"}>
{L("account.group_name")}
</FormLabel>
<FormControl>
<TextField maxLength={32} value={group.name}
size={"small"} name={"name"}
placeholder={L("account.name")}
onChange={e => setGroup({...group, name: e.target.value})}/>
</FormControl>
</FormGroup>
<div className={"form-group"}>
<label htmlFor={"color"}>
{L("account.color")}
</label>
<div>
<ColorPicker
value={group.color}
size={"small"}
variant={"outlined"}
style={{ backgroundColor: group.color }}
floatingLabelText={group.color}
onChange={color => setGroup({...group, color: color})}
/>
</div>
</div>
<FormGroup className={"my-2"}>
<FormLabel htmlFor={"color"}>
{L("account.color")}
</FormLabel>
<FormControl>
<ColorPicker
value={group.color}
size={"small"}
variant={"outlined"}
style={{ backgroundColor: group.color }}
floatingLabelText={group.color}
onChange={color => setGroup({...group, color: color})}
/>
</FormControl>
</FormGroup>
<Link to={"/admin/groups"} className={"btn btn-info mt-2 mr-2"}>
&nbsp;{L("general.go_back")}
</Link>
<button type={"submit"} className={"btn btn-primary mt-2"}>
{L("general.submit")}
</button>
</form>
</div>
<div className={"col-6"}>
<h3>{L("account.members")}</h3>
<ButtonBar mt={2}>
<Button startIcon={<KeyboardArrowLeft />}
variant={"outlined"}
onClick={() => navigate("/admin/groups")}>
{L("general.cancel")}
</Button>
<Button startIcon={<Save />} color={"primary"}
variant={"outlined"} disabled={isSaving}
onClick={onSave}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
</ButtonBar>
</div>
</div>
{!isNewGroup && api.hasPermission("groups/getMembers") ?
<div className={"m-3"}>
<div className={"col-6"}>
<DataTable
data={members}
pagination={pagination}
defaultSortOrder={"asc"}
defaultSortColumn={0}
className={"table table-striped"}
fetchData={onFetchMembers}
placeholder={L("No members in this group")}
title={L("account.members")}
columns={[
new NumericColumn(L("general.id"), "id"),
new StringColumn(L("account.name"), "name"),
new StringColumn(L("account.full_name"), "fullName"),
new ControlsColumn(L("general.controls"), [
{
label: L("general.edit"),
element: EditIcon,
onClick: (entry) => navigate(`/admin/user/${entry.id}`)
},
{
label: L("general.remove"),
element: Delete,
disabled: !api.hasPermission("groups/removeMember"),
onClick: (entry) => setDialogData({
open: true,
title: L("Remove member"),
message: sprintf(L("Do you really want to remove user '%s' from this group?"), entry.fullName || entry.name),
onOption: (option) => option === 0 && onRemoveMember(entry.id)
})
}
]),
]}
/>
</div>
</div>
: <></>
}
</div>
<Dialog show={dialogData.open}
onClose={() => setDialogData({open: false})}
title={dialogData.title}
message={dialogData.message}
onOption={dialogData.onOption}
inputs={dialogData.inputs}
options={[L("general.ok"), L("general.cancel")]} />
</>
}

@ -1,7 +1,7 @@
import {Link, useNavigate} from "react-router-dom";
import {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale";
import {DataColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
import {ControlsColumn, DataColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
import {Button, IconButton} from "@material-ui/core";
import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add';
@ -28,7 +28,6 @@ export default function GroupListView(props) {
}, [currentLocale]);
const onFetchGroups = useCallback(async (page, count, orderBy, sortOrder) => {
api.fetchGroups(page, count, orderBy, sortOrder).then((res) => {
if (res.success) {
setGroups(res.groups);
@ -40,21 +39,13 @@ export default function GroupListView(props) {
});
}, [api, pagination]);
const actionColumn = (() => {
let column = new DataColumn(L("general.actions"), null, false);
column.renderData = (L, entry) => <>
<IconButton size={"small"} title={L("general.edit")} onClick={() => navigate("/admin/group/" + entry.id)}>
<EditIcon />
</IconButton>
</>
return column;
})();
const columnDefinitions = [
new NumericColumn(L("general.id"), "id"),
new StringColumn(L("account.name"), "name"),
new NumericColumn(L("account.member_count"), "memberCount"),
actionColumn,
new NumericColumn(L("account.member_count"), "memberCount", { align: "center" }),
new ControlsColumn(L("general.controls"), [
{ label: L("general.edit"), element: EditIcon, onClick: (entry) => navigate(`/admin/group/${entry.id}`) }
]),
];
return <>

@ -153,14 +153,6 @@ export default class API {
return this.apiCall("user/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder });
}
async fetchGroups(pageNum = 1, count = 20, orderBy = 'id', sortOrder = 'asc') {
return this.apiCall("groups/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder });
}
async getGroup(id) {
return this.apiCall("groups/get", { id: id });
}
async inviteUser(username, email) {
return this.apiCall("user/invite", { username: username, email: email });
}
@ -207,6 +199,39 @@ export default class API {
return res;
}
/** Groups API **/
async fetchGroups(pageNum = 1, count = 20, orderBy = 'id', sortOrder = 'asc') {
return this.apiCall("groups/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder });
}
async fetchGroupMembers(groupId, pageNum = 1, count = 20, orderBy = 'id', sortOrder = 'asc') {
return this.apiCall("groups/getMembers", { id: groupId, page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder });
}
async removeGroupMember (groupId, userId) {
return this.apiCall("groups/removeMember", { id: groupId, userId: userId });
}
async addGroupMember (groupId, userId) {
return this.apiCall("groups/addMember", { id: groupId, userId: userId });
}
async getGroup(id) {
return this.apiCall("groups/get", { id: id });
}
async createGroup(name, color) {
return this.apiCall("groups/create", { name: name, color: color });
}
async updateGroup(id, name, color) {
return this.apiCall("groups/update", { id: id, name: name, color: color });
}
async deleteGroup(id) {
return this.apiCall("groups/delete", { id: id });
}
/** Stats **/
async getStats() {
return this.apiCall("stats");
@ -249,15 +274,6 @@ export default class API {
return this.apiCall("routes/update", { id, pattern, type, target, extra, exact, active });
}
/** GroupAPI **/
async createGroup(name, color) {
return this.apiCall("groups/create", { name: name, color: color });
}
async deleteGroup(id) {
return this.apiCall("groups/delete", { id: id });
}
/** SettingsAPI **/
async getSettings(key = "") {
return this.apiCall("settings/get", { key: key });