From 91520dd26ceb1510e0b1b1ad40fd5c16738a5294 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 3 May 2024 23:07:50 +0200 Subject: [PATCH] User Create/Edit/Invite frontend + backend --- Core/API/Parameter/ArrayType.class.php | 14 +- Core/API/UserAPI.class.php | 70 +++++--- react/admin-panel/src/elements/sidebar.js | 1 - .../src/views/profile/edit-picture.js | 2 +- react/admin-panel/src/views/user/user-edit.js | 154 ++++++++++++++---- react/shared/api.js | 16 +- 6 files changed, 195 insertions(+), 62 deletions(-) diff --git a/Core/API/Parameter/ArrayType.class.php b/Core/API/Parameter/ArrayType.class.php index d55b154..3eeee0b 100644 --- a/Core/API/Parameter/ArrayType.class.php +++ b/Core/API/Parameter/ArrayType.class.php @@ -16,14 +16,16 @@ class ArrayType extends Parameter { * @param bool $optional true if the parameter is optional * @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->elementParameter = new Parameter('', $elementType); $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 { + if (!is_array($value)) { if (!$this->canBeOne) { 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; return true; } diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index 5a26c4f..fc1a42f 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -2,8 +2,11 @@ namespace Core\API { + use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Condition\Compare; + use Core\Driver\SQL\Condition\CondIn; use Core\Objects\Context; + use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Language; use Core\Objects\DatabaseEntity\User; use Core\Objects\DatabaseEntity\UserToken; @@ -75,6 +78,26 @@ namespace Core\API { $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 { $sql = $this->context->getSQL(); @@ -153,6 +176,7 @@ namespace Core\API\User { public function __construct(Context $context, $externalCall = false) { parent::__construct($context, $externalCall, array( 'username' => new StringType('username', 32), + 'fullName' => new StringType('fullName', 64, true, ""), 'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL), 'password' => new StringType('password'), 'confirmPassword' => new StringType('confirmPassword'), @@ -165,6 +189,7 @@ namespace Core\API\User { public function _execute(): bool { $username = $this->getParam('username'); + $fullName = $this->getParam('fullName'); $email = $this->getParam('email'); $password = $this->getParam('password'); $confirmPassword = $this->getParam('confirmPassword'); @@ -172,31 +197,18 @@ namespace Core\API\User { return false; } + $groups = []; + if (!$this->checkGroups($groups)) { + return false; + } + if (!$this->checkUserExists($username, $email)) { 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 $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) { $this->user = $user; $this->result["userId"] = $user->getId(); @@ -432,7 +444,9 @@ namespace Core\API\User { public function __construct(Context $context, $externalCall = false) { parent::__construct($context, $externalCall, array( 'username' => new StringType('username', 32), + 'fullName' => new StringType('fullName', 64, true, ""), 'email' => new StringType('email', 64), + 'groups' => new ArrayType("groups", Parameter::TYPE_INT, true, true, []) )); $this->loginRequired = true; @@ -440,24 +454,38 @@ namespace Core\API\User { 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'); + $fullName = $this->getParam('fullName'); $email = $this->getParam('email'); + $groups = []; + + if (!$this->checkGroups($groups)) { + return false; + } + if (!$this->checkUserExists($username, $email)) { return false; } // Create user - $user = $this->insertUser($username, $email, "", false); + $user = $this->insertUser($username, $email, "", false, $fullName, $groups); if ($user === 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()); // Create Token $token = generateRandomString(36); $validDays = 7; - $sql = $this->context->getSQL(); $userToken = new UserToken($user, $token, UserToken::TYPE_INVITE, $validDays * 24); if ($userToken->save($sql)) { diff --git a/react/admin-panel/src/elements/sidebar.js b/react/admin-panel/src/elements/sidebar.js index b6a6a0c..bb32543 100644 --- a/react/admin-panel/src/elements/sidebar.js +++ b/react/admin-panel/src/elements/sidebar.js @@ -10,7 +10,6 @@ import { import { Dropdown } from '@mui/base/Dropdown'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; 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 useCurrentPath from "shared/hooks/current-path"; import ProfileLink from "./profile-link"; diff --git a/react/admin-panel/src/views/profile/edit-picture.js b/react/admin-panel/src/views/profile/edit-picture.js index b15e28c..c3541af 100644 --- a/react/admin-panel/src/views/profile/edit-picture.js +++ b/react/admin-panel/src/views/profile/edit-picture.js @@ -107,7 +107,7 @@ export default function EditProfilePicture(props) { if (croppedSize < 150) { 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 { setImage({ loading: false, file: file, data: imageData }); } diff --git a/react/admin-panel/src/views/user/user-edit.js b/react/admin-panel/src/views/user/user-edit.js index 0ab6b86..aa0ba31 100644 --- a/react/admin-panel/src/views/user/user-edit.js +++ b/react/admin-panel/src/views/user/user-edit.js @@ -7,17 +7,28 @@ import { CircularProgress, FormControl, FormControlLabel, - FormLabel, - TextField + FormLabel, Grid, + TextField, + FormGroup as MuiFormGroup } from "@mui/material"; import {LocaleContext} from "shared/locale"; import * as React from "react"; import ViewContent from "../../elements/view-content"; import FormGroup from "../../elements/form-group"; import ButtonBar from "../../elements/button-bar"; -import {RestartAlt, Save} from "@mui/icons-material"; -import {parseBool} from "shared/util"; -import SpacedFormGroup from "../../elements/form-group"; +import {RestartAlt, Save, Send} from "@mui/icons-material"; +import PasswordStrength from "shared/elements/password-strength"; + +const initialUser = { + name: "", + fullName: "", + email: "", + password: "", + passwordConfirm: "", + groups: [], + confirmed: false, + active: true, +}; export default function UserEditView(props) { @@ -30,19 +41,12 @@ export default function UserEditView(props) { const isNewUser = userId === "new"; const {translate: L, requestModules, currentLocale} = useContext(LocaleContext); const [fetchUser, setFetchUser] = useState(!isNewUser); - const [user, setUser] = useState(isNewUser ? { - name: "", - fullName: "", - email: "", - password: "", - groups: [], - confirmed: false, - active: true, - } : null); + const [user, setUser] = useState(isNewUser ? initialUser : null); // ui const [hasChanged, setChanged] = useState(isNewUser); const [isSaving, setSaving] = useState(false); + const [sendInvite, setSetInvite] = useState(isNewUser); useEffect(() => { requestModules(props.api, ["general", "account"], currentLocale).then(data => { @@ -52,14 +56,6 @@ export default function UserEditView(props) { }); }, [currentLocale]); - const onReset = useCallback(() => { - - }, []); - - const onSaveUser = useCallback(() => { - - }, []); - const onFetchUser = useCallback((force = false) => { if (!isNewUser && (force || fetchUser)) { setFetchUser(false); @@ -76,9 +72,60 @@ export default function UserEditView(props) { } }, [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(() => { if (!isNewUser) { @@ -95,7 +142,8 @@ export default function UserEditView(props) { User, {isNewUser ? "New" : "Edit"} ]}> - + + {L("account.name")} @@ -124,12 +172,22 @@ export default function UserEditView(props) { { !isNewUser ? <> + {L("account.password")} + + setUser({...user, password: e.target.value})} /> + + + onChangeValue("active", v)} />} label={L("account.active")} /> - + : <> - - + + setSetInvite(v)} />} + label={L("account.send_invite")} /> + + {!sendInvite && <> + + {L("account.password")} + + setUser({...user, password: e.target.value})} /> + + + + {L("account.password_confirm")} + + setUser({...user, passwordConfirm: e.target.value})} /> + + + + + + + } } - + +