User Create/Edit/Invite frontend + backend

This commit is contained in:
Roman 2024-05-03 23:07:50 +02:00
parent 675025800b
commit 91520dd26c
6 changed files with 195 additions and 62 deletions

@ -16,14 +16,16 @@ class ArrayType extends Parameter {
* @param bool $optional true if the parameter is optional * @param bool $optional true if the parameter is optional
* @param array|null $defaultValue the default value to use, if the parameter is not given * @param array|null $defaultValue the default value to use, if the parameter is not given
*/ */
public function __construct(string $name, int $elementType = Parameter::TYPE_MIXED, bool $canBeOne = false, bool $optional = FALSE, ?array $defaultValue = NULL) { public function __construct(string $name, int $elementType = Parameter::TYPE_MIXED, bool $canBeOne = false,
bool $optional = FALSE, ?array $defaultValue = NULL, ?array $choices = NULL) {
$this->elementType = $elementType; $this->elementType = $elementType;
$this->elementParameter = new Parameter('', $elementType); $this->elementParameter = new Parameter('', $elementType);
$this->canBeOne = $canBeOne; $this->canBeOne = $canBeOne;
parent::__construct($name, Parameter::TYPE_ARRAY, $optional, $defaultValue); parent::__construct($name, Parameter::TYPE_ARRAY, $optional, $defaultValue, $choices);
} }
public function parseParam($value): bool { public function parseParam($value): bool {
if (!is_array($value)) { if (!is_array($value)) {
if (!$this->canBeOne) { if (!$this->canBeOne) {
return false; return false;
@ -42,6 +44,14 @@ class ArrayType extends Parameter {
} }
} }
if (!is_null($this->choices)) {
foreach ($value as $element) {
if (!in_array($element, $this->choices)) {
return false;
}
}
}
$this->value = $value; $this->value = $value;
return true; return true;
} }

@ -2,8 +2,11 @@
namespace Core\API { namespace Core\API {
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondIn;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Language; use Core\Objects\DatabaseEntity\Language;
use Core\Objects\DatabaseEntity\User; use Core\Objects\DatabaseEntity\User;
use Core\Objects\DatabaseEntity\UserToken; use Core\Objects\DatabaseEntity\UserToken;
@ -75,6 +78,26 @@ namespace Core\API {
$this->checkPasswordRequirements($password, $confirmPassword); $this->checkPasswordRequirements($password, $confirmPassword);
} }
protected function checkGroups(array &$groups): bool {
$sql = $this->context->getSQL();
$currentUser = $this->context->getUser();
$requestedGroups = array_unique($this->getParam("groups"));
if (!empty($requestedGroups)) {
$availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups));
foreach ($requestedGroups as $groupId) {
if (!isset($availableGroups[$groupId])) {
return $this->createError("Group with id=$groupId does not exist.");
} else if ($this->isExternalCall() && $groupId === Group::ADMIN && !$currentUser->hasGroup(Group::ADMIN)) {
return $this->createError("You cannot create users with administrator groups.");
} else {
$groups[] = $groupId;
}
}
}
return true;
}
protected function insertUser(string $username, ?string $email, string $password, bool $confirmed, string $fullName = "", array $groups = []): bool|User { protected function insertUser(string $username, ?string $email, string $password, bool $confirmed, string $fullName = "", array $groups = []): bool|User {
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
@ -153,6 +176,7 @@ namespace Core\API\User {
public function __construct(Context $context, $externalCall = false) { public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array( parent::__construct($context, $externalCall, array(
'username' => new StringType('username', 32), 'username' => new StringType('username', 32),
'fullName' => new StringType('fullName', 64, true, ""),
'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL), 'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL),
'password' => new StringType('password'), 'password' => new StringType('password'),
'confirmPassword' => new StringType('confirmPassword'), 'confirmPassword' => new StringType('confirmPassword'),
@ -165,6 +189,7 @@ namespace Core\API\User {
public function _execute(): bool { public function _execute(): bool {
$username = $this->getParam('username'); $username = $this->getParam('username');
$fullName = $this->getParam('fullName');
$email = $this->getParam('email'); $email = $this->getParam('email');
$password = $this->getParam('password'); $password = $this->getParam('password');
$confirmPassword = $this->getParam('confirmPassword'); $confirmPassword = $this->getParam('confirmPassword');
@ -172,31 +197,18 @@ namespace Core\API\User {
return false; return false;
} }
$groups = [];
if (!$this->checkGroups($groups)) {
return false;
}
if (!$this->checkUserExists($username, $email)) { if (!$this->checkUserExists($username, $email)) {
return false; return false;
} }
$groups = [];
$sql = $this->context->getSQL();
$currentUser = $this->context->getUser();
$requestedGroups = array_unique($this->getParam("groups"));
if (!empty($requestedGroups)) {
$availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups));
foreach ($requestedGroups as $groupId) {
if (!isset($availableGroups[$groupId])) {
return $this->createError("Group with id=$groupId does not exist.");
} else if ($this->isExternalCall() && $groupId === Group::ADMIN && !$currentUser->hasGroup(Group::ADMIN)) {
return $this->createError("You cannot create users with administrator groups.");
} else {
$groups[] = $groupId;
}
}
}
// prevent duplicate keys // prevent duplicate keys
$email = (!is_null($email) && empty($email)) ? null : $email; $email = (!is_null($email) && empty($email)) ? null : $email;
$user = $this->insertUser($username, $email, $password, true, "", $groups); $user = $this->insertUser($username, $email, $password, true, $fullName, $groups);
if ($user !== false) { if ($user !== false) {
$this->user = $user; $this->user = $user;
$this->result["userId"] = $user->getId(); $this->result["userId"] = $user->getId();
@ -432,7 +444,9 @@ namespace Core\API\User {
public function __construct(Context $context, $externalCall = false) { public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array( parent::__construct($context, $externalCall, array(
'username' => new StringType('username', 32), 'username' => new StringType('username', 32),
'fullName' => new StringType('fullName', 64, true, ""),
'email' => new StringType('email', 64), 'email' => new StringType('email', 64),
'groups' => new ArrayType("groups", Parameter::TYPE_INT, true, true, [])
)); ));
$this->loginRequired = true; $this->loginRequired = true;
@ -440,24 +454,38 @@ namespace Core\API\User {
public function _execute(): bool { public function _execute(): bool {
$sql = $this->context->getSQL();
$settings = $this->context->getSettings();
$currentUser = $this->context->getUser();
if (!$settings->isMailEnabled()) {
return $this->createError("An invitation cannot be sent because mailing is not enabled.");
}
$username = $this->getParam('username'); $username = $this->getParam('username');
$fullName = $this->getParam('fullName');
$email = $this->getParam('email'); $email = $this->getParam('email');
$groups = [];
if (!$this->checkGroups($groups)) {
return false;
}
if (!$this->checkUserExists($username, $email)) { if (!$this->checkUserExists($username, $email)) {
return false; return false;
} }
// Create user // Create user
$user = $this->insertUser($username, $email, "", false); $user = $this->insertUser($username, $email, "", false, $fullName, $groups);
if ($user === false) { if ($user === false) {
return false; return false;
} }
$this->result["userId"] = $user->getId();
$this->logger->info("A new user with username='$username' and email='$email' was invited by " . $this->logUserId()); $this->logger->info("A new user with username='$username' and email='$email' was invited by " . $this->logUserId());
// Create Token // Create Token
$token = generateRandomString(36); $token = generateRandomString(36);
$validDays = 7; $validDays = 7;
$sql = $this->context->getSQL();
$userToken = new UserToken($user, $token, UserToken::TYPE_INVITE, $validDays * 24); $userToken = new UserToken($user, $token, UserToken::TYPE_INVITE, $validDays * 24);
if ($userToken->save($sql)) { if ($userToken->save($sql)) {

@ -10,7 +10,6 @@ import {
import { Dropdown } from '@mui/base/Dropdown'; import { Dropdown } from '@mui/base/Dropdown';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ProfilePicture from "shared/elements/profile-picture";
import {Dns, Groups, People, QueryStats, Security, Settings, Route, ArrowBack, Translate} from "@mui/icons-material"; import {Dns, Groups, People, QueryStats, Security, Settings, Route, ArrowBack, Translate} from "@mui/icons-material";
import useCurrentPath from "shared/hooks/current-path"; import useCurrentPath from "shared/hooks/current-path";
import ProfileLink from "./profile-link"; import ProfileLink from "./profile-link";

@ -107,7 +107,7 @@ export default function EditProfilePicture(props) {
if (croppedSize < 150) { if (croppedSize < 150) {
setImage({ loading: false, file: null, data: null }); setImage({ loading: false, file: null, data: null });
showDialog(L("account.profile_picture_invalid_dimensions"), L("general.error")); showDialog(L("account.profile_picture_invalid_dimensions"), L("general.error_occurred"));
} else { } else {
setImage({ loading: false, file: file, data: imageData }); setImage({ loading: false, file: file, data: imageData });
} }

@ -7,17 +7,28 @@ import {
CircularProgress, CircularProgress,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
FormLabel, FormLabel, Grid,
TextField TextField,
FormGroup as MuiFormGroup
} from "@mui/material"; } from "@mui/material";
import {LocaleContext} from "shared/locale"; import {LocaleContext} from "shared/locale";
import * as React from "react"; import * as React from "react";
import ViewContent from "../../elements/view-content"; import ViewContent from "../../elements/view-content";
import FormGroup from "../../elements/form-group"; import FormGroup from "../../elements/form-group";
import ButtonBar from "../../elements/button-bar"; import ButtonBar from "../../elements/button-bar";
import {RestartAlt, Save} from "@mui/icons-material"; import {RestartAlt, Save, Send} from "@mui/icons-material";
import {parseBool} from "shared/util"; import PasswordStrength from "shared/elements/password-strength";
import SpacedFormGroup from "../../elements/form-group";
const initialUser = {
name: "",
fullName: "",
email: "",
password: "",
passwordConfirm: "",
groups: [],
confirmed: false,
active: true,
};
export default function UserEditView(props) { export default function UserEditView(props) {
@ -30,19 +41,12 @@ export default function UserEditView(props) {
const isNewUser = userId === "new"; const isNewUser = userId === "new";
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext); const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);
const [fetchUser, setFetchUser] = useState(!isNewUser); const [fetchUser, setFetchUser] = useState(!isNewUser);
const [user, setUser] = useState(isNewUser ? { const [user, setUser] = useState(isNewUser ? initialUser : null);
name: "",
fullName: "",
email: "",
password: "",
groups: [],
confirmed: false,
active: true,
} : null);
// ui // ui
const [hasChanged, setChanged] = useState(isNewUser); const [hasChanged, setChanged] = useState(isNewUser);
const [isSaving, setSaving] = useState(false); const [isSaving, setSaving] = useState(false);
const [sendInvite, setSetInvite] = useState(isNewUser);
useEffect(() => { useEffect(() => {
requestModules(props.api, ["general", "account"], currentLocale).then(data => { requestModules(props.api, ["general", "account"], currentLocale).then(data => {
@ -52,14 +56,6 @@ export default function UserEditView(props) {
}); });
}, [currentLocale]); }, [currentLocale]);
const onReset = useCallback(() => {
}, []);
const onSaveUser = useCallback(() => {
}, []);
const onFetchUser = useCallback((force = false) => { const onFetchUser = useCallback((force = false) => {
if (!isNewUser && (force || fetchUser)) { if (!isNewUser && (force || fetchUser)) {
setFetchUser(false); setFetchUser(false);
@ -76,9 +72,60 @@ export default function UserEditView(props) {
} }
}, [api, showDialog, fetchUser, isNewUser, userId, user]); }, [api, showDialog, fetchUser, isNewUser, userId, user]);
const onChangeValue = useCallback((name, value) => { const onReset = useCallback(() => {
if (isNewUser) {
setUser({...initialUser});
} else {
onFetchUser(true);
}
}, [isNewUser, onFetchUser]);
}, []); const onSaveUser = useCallback(() => {
if (!isSaving) {
setSaving(true);
if (isNewUser) {
if (sendInvite) {
api.inviteUser(user.name, user.fullName, user.email).then(res => {
setSaving(false);
if (res.success) {
setChanged(false);
navigate("/admin/user/" + res.userId);
} else {
showDialog(res.msg, L("account.invite_user_error"));
}
});
} else {
api.createUser(user.name, user.fullName, user.email, user.password, user.passwordConfirm).then(res => {
setSaving(false);
if (res.success) {
setChanged(false);
navigate("/admin/user/" + res.userId);
} else {
showDialog(res.msg, L("account.create_user_error"));
}
});
}
} else {
api.editUser(
userId, user.name, user.email, user.password,
user.groups, user.confirmed, user.active
).then(res => {
setSaving(false);
if (res.success) {
setChanged(false);
} else {
showDialog(res.msg, L("account.save_user_error"));
}
});
}
}
}, [isSaving, sendInvite, isNewUser, userId, showDialog]);
const onChangeValue = useCallback((name, value) => {
setUser({...user, [name]: value});
setChanged(true);
}, [user]);
useEffect(() => { useEffect(() => {
if (!isNewUser) { if (!isNewUser) {
@ -95,7 +142,8 @@ export default function UserEditView(props) {
<Link key={"users"} to={"/admin/users"}>User</Link>, <Link key={"users"} to={"/admin/users"}>User</Link>,
<span key={"action"}>{isNewUser ? "New" : "Edit"}</span> <span key={"action"}>{isNewUser ? "New" : "Edit"}</span>
]}> ]}>
<Box> <Grid container>
<Grid item xs={12} lg={6}>
<FormGroup> <FormGroup>
<FormLabel>{L("account.name")}</FormLabel> <FormLabel>{L("account.name")}</FormLabel>
<FormControl> <FormControl>
@ -124,12 +172,22 @@ export default function UserEditView(props) {
{ !isNewUser ? { !isNewUser ?
<> <>
<FormGroup> <FormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.password}
type={"password"}
placeholder={"(" + L("general.unchanged") + ")"}
onChange={e => setUser({...user, password: e.target.value})} />
</FormControl>
</FormGroup>
<MuiFormGroup>
<FormControlLabel <FormControlLabel
control={<Checkbox control={<Checkbox
checked={!!user.active} checked={!!user.active}
onChange={(e, v) => onChangeValue("active", v)} />} onChange={(e, v) => onChangeValue("active", v)} />}
label={L("account.active")} /> label={L("account.active")} />
</FormGroup> </MuiFormGroup>
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
control={<Checkbox control={<Checkbox
@ -138,18 +196,52 @@ export default function UserEditView(props) {
label={L("account.confirmed")} /> label={L("account.confirmed")} />
</FormGroup> </FormGroup>
</> : <> </> : <>
<FormGroup>
<FormControlLabel
control={<Checkbox
checked={sendInvite}
onChange={(e, v) => setSetInvite(v)} />}
label={L("account.send_invite")} />
</FormGroup>
{!sendInvite && <>
<FormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.password}
type={"password"}
onChange={e => setUser({...user, password: e.target.value})} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.password_confirm")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.passwordConfirm}
type={"password"}
onChange={e => setUser({...user, passwordConfirm: e.target.value})} />
</FormControl>
</FormGroup>
<Box mb={2}>
<PasswordStrength password={user.password} />
</Box>
</> </>
} }
</Box> </>
}
</Grid>
</Grid>
<ButtonBar> <ButtonBar>
<Button color={"primary"} <Button color={"primary"}
onClick={onSaveUser} onClick={onSaveUser}
disabled={isSaving || !(isNewUser ? api.hasPermission("user/create") : api.hasPermission("user/edit"))} disabled={isSaving || !(isNewUser ? api.hasPermission("user/create") : api.hasPermission("user/edit"))}
startIcon={isSaving ? <CircularProgress size={14} /> : <Save />} startIcon={isSaving ?
<CircularProgress size={14} /> :
(sendInvite ? <Send /> : <Save /> )}
variant={"outlined"} title={L(hasChanged ? "general.unsaved_changes" : "general.save")}> variant={"outlined"} title={L(hasChanged ? "general.unsaved_changes" : "general.save")}>
{isSaving ? L("general.saving") + "…" : (L("general.save") + (hasChanged ? " *" : ""))} {isSaving ?
L(sendInvite ? "general.sending" : "general.saving") + "…" :
(L(sendInvite ? "general.send" : "general.save") + (hasChanged ? " *" : ""))}
</Button> </Button>
<Button color={"error"} <Button color={"error"}
onClick={onReset} onClick={onReset}

@ -127,10 +127,11 @@ export default class API {
return res; return res;
} }
async editUser(id, username, email, password, groups, confirmed) { async editUser(id, username, email, password, groups, confirmed, active) {
return this.apiCall("user/edit", { return this.apiCall("user/edit", {
id: id, username: username, email: email, id: id, username: username, email: email,
password: password, groups: groups, confirmed: confirmed password: password, groups: groups,
confirmed: confirmed, active: active
}); });
} }
@ -158,12 +159,15 @@ export default class API {
return this.apiCall("user/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder }); return this.apiCall("user/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder });
} }
async inviteUser(username, email) { async inviteUser(username, fullName, email) {
return this.apiCall("user/invite", { username: username, email: email }); return this.apiCall("user/invite", { username: username, fullName: fullName, email: email });
} }
async createUser(username, email, password, confirmPassword) { async createUser(username, fullName, email, password, confirmPassword) {
return this.apiCall("user/create", { username: username, email: email, password: password, confirmPassword: confirmPassword }); return this.apiCall("user/create", { username: username, email: email,
fullName: fullName,
password: password, confirmPassword: confirmPassword
});
} }
async searchUser(query) { async searchUser(query) {