diff --git a/Core/API/GroupsAPI.class.php b/Core/API/GroupsAPI.class.php index 3f8c7f4..9f4b7a0 100644 --- a/Core/API/GroupsAPI.class.php +++ b/Core/API/GroupsAPI.class.php @@ -54,11 +54,16 @@ namespace Core\API { namespace Core\API\Groups { use Core\API\GroupsAPI; + use Core\API\Parameter\ArrayType; use Core\API\Parameter\Parameter; use Core\API\Parameter\RegexType; + use Core\API\Parameter\StringType; use Core\API\Traits\Pagination; use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Condition\Compare; + use Core\Driver\SQL\Condition\CondIn; + use Core\Driver\SQL\Condition\CondLike; + use Core\Driver\SQL\Condition\CondNot; use Core\Driver\SQL\Expression\Alias; use Core\Driver\SQL\Expression\Count; use Core\Driver\SQL\Join\InnerJoin; @@ -118,6 +123,48 @@ namespace Core\API\Groups { } } + class Search extends GroupsAPI { + public function __construct(Context $context, bool $externalCall = false) { + parent::__construct($context, $externalCall, [ + "query" => new StringType("query", -1, true, NULL), + "exclude" => new ArrayType("exclude", Parameter::TYPE_INT, true, true, []) + ]); + } + + protected function _execute(): bool { + $sql = $this->context->getSQL(); + $query = $this->getParam("query"); + $exclude = array_unique($this->getParam("exclude")); + + $groupsQuery = Group::createBuilder($sql, false) + ->limit(5); + + if (!empty($query)) { + $groupsQuery->where(new CondLike(new Column("name"), "%$query%")); + } + + if (!empty($exclude)) { + $groupsQuery->where(new CondNot(new CondIn(new Column("id"), $exclude))); + } + + $groups = Group::findBy($groupsQuery); + if ($groups === false) { + return $this->createError($sql->getLastError()); + } + + $this->result["groups"] = $groups; + return true; + } + + public static function getDescription(): string { + return "Returns a list of groups matching the search criteria"; + } + + public static function getDefaultPermittedGroups(): array { + return [Group::ADMIN, Group::SUPPORT]; + } + } + class Get extends GroupsAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, [ diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index fc1a42f..94253b8 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -885,6 +885,7 @@ namespace Core\API\User { return $this->createError("User not found"); } + $columnsToUpdate = []; $username = $this->getParam("username"); $fullName = $this->getParam("fullName"); $email = $this->getParam("email"); @@ -892,10 +893,8 @@ namespace Core\API\User { $groups = $this->getParam("groups"); $confirmed = $this->getParam("confirmed"); $active = $this->getParam("active"); - $email = (!is_null($email) && empty($email)) ? null : $email; - $groupIds = array(); if (!is_null($groups)) { $groupIds = array_unique($groups); if ($id === $currentUser->getId() && !in_array(Group::ADMIN, $groupIds)) { @@ -910,6 +909,9 @@ namespace Core\API\User { return $this->createError("Group with id=$groupId does not exist."); } } + + $user->groups = $groupIds; + $columnsToUpdate[] = "groups"; } // Check for duplicate username, email @@ -922,7 +924,6 @@ namespace Core\API\User { } } - $columnsToUpdate = []; if ($usernameChanged) { $user->name = $username; $columnsToUpdate[] = "name"; @@ -961,18 +962,11 @@ namespace Core\API\User { } } - if (empty($columnsToUpdate) || $user->save($sql, $columnsToUpdate)) { - - $deleteQuery = $sql->delete("UserGroup")->whereEq("user_id", $id); - $insertQuery = $sql->insert("UserGroup", array("user_id", "group_id")); - - foreach ($groupIds as $groupId) { - $insertQuery->addRow($id, $groupId); - } - - $this->success = ($deleteQuery->execute() !== FALSE) && (empty($groupIds) || $insertQuery->execute() !== FALSE); + if (!empty($columnsToUpdate)) { + $this->success = $user->save($sql, $columnsToUpdate, in_array("groups", $columnsToUpdate)) !== FALSE; $this->lastError = $sql->getLastError(); } + } else { return $this->createError("Error fetching user details: " . $sql->getLastError()); } @@ -1402,7 +1396,7 @@ namespace Core\API\User { } $oldPfp = $currentUser->getProfilePicture(); - if ($oldPfp) { + if ($oldPfp && preg_match("/[a-fA-F0-9-]+\.(jpg|jpeg|png|gif)/", $oldPfp)) { $path = "$uploadDir/$oldPfp"; if (is_file($path)) { @unlink($path); @@ -1446,9 +1440,11 @@ namespace Core\API\User { return $this->createError("Error updating user details: " . $sql->getLastError()); } - $path = WEBROOT . "/img/uploads/user/$userId/$pfp"; - if (is_file($path)) { - @unlink($path); + if (preg_match("/[a-fA-F0-9-]+\.(jpg|jpeg|png|gif)/", $pfp)) { + $path = WEBROOT . "/img/uploads/user/$userId/$pfp"; + if (is_file($path)) { + @unlink($path); + } } return $this->success; diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index 7e6cb5d..5e7174d 100644 --- a/Core/Localization/de_DE/account.php +++ b/Core/Localization/de_DE/account.php @@ -57,6 +57,15 @@ return [ "no_members" => "Keine Mitglieder in dieser Gruppe", "user_list_placeholder" => "Keine Benutzer zum Anzeigen", + # user edit page + "edit_user" => "Benutzer bearbeiten", + "new_user" => "Neuer Benutzer", + "send_invite" => "Einladung versenden", + "get_user_error" => "Fehler beim Holen des Benutzers", + "invite_user_error" => "Fehler beim Versenden der Einladung", + "create_user_error" => "Fehler beim Erstellen des Benutzers", + "save_user_error" => "Fehler beim Speichern des Benutzers", + # profile picture "remove_picture" => "Profilbild entfernen", "remove_picture_text" => "Möchten Sie wirklich Ihr aktuelles Profilbild entfernen?", @@ -74,6 +83,7 @@ return [ "update_group_error" => "Error beim Aktualisieren der Gruppe", "delete_group_error" => "Error beim Löschen der Gruppe", "search_users_error" => "Fehler beim Suchen des Benutzers", + "search_groups_error" => "Fehler beim Suchen der Gruppen", "delete_group_title" => "Gruppe löschen", "delete_group_text" => "Möchten Sie diese Gruppe wirklich löschen? Dies kann nicht rückgängig gemacht werden.", "remove_group_member_title" => "Mitglied entfernen", diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index 31a8b8b..989668a 100644 --- a/Core/Localization/en_US/account.php +++ b/Core/Localization/en_US/account.php @@ -59,6 +59,15 @@ return [ "edit_profile" => "Edit Profile", "user_list_placeholder" => "No users to display", + # user edit page + "edit_user" => "Edit User", + "new_user" => "New User", + "send_invite" => "Send Invitation", + "get_user_error" => "Error fetching user", + "invite_user_error" => "Error sending invitation", + "create_user_error" => "Error creating user", + "save_user_error" => "Error saving user", + # profile picture "remove_picture" => "Remove profile picture", "remove_picture_text" => "Do you really want to remove your current profile picture?", @@ -76,6 +85,7 @@ return [ "update_group_error" => "Error updating group", "delete_group_error" => "Error deleting group", "search_users_error" => "Error searching users", + "search_groups_error" => "Error searching groups", "delete_group_title" => "Delete Group", "delete_group_text" => "Do you really want to delete this group? This action cannot be undone.", "remove_group_member_title" => "Remove member", diff --git a/react/admin-panel/src/elements/sidebar.js b/react/admin-panel/src/elements/sidebar.js index bb32543..e9114ff 100644 --- a/react/admin-panel/src/elements/sidebar.js +++ b/react/admin-panel/src/elements/sidebar.js @@ -76,7 +76,7 @@ const StyledDrawer = styled(Drawer, { shouldForwardProp: (prop) => prop !== 'ope export default function Sidebar(props) { - const {api, showDialog, theme, children, ...other} = props; + const {api, showDialog, hideDialog, theme, info, children, ...other} = props; const {translate: L, currentLocale, setLanguageByCode} = useContext(LocaleContext); const [languages, setLanguages] = useState(null); diff --git a/react/admin-panel/src/views/user/user-edit.js b/react/admin-panel/src/views/user/user-edit.js index aa0ba31..81e3ed3 100644 --- a/react/admin-panel/src/views/user/user-edit.js +++ b/react/admin-panel/src/views/user/user-edit.js @@ -9,7 +9,7 @@ import { FormControlLabel, FormLabel, Grid, TextField, - FormGroup as MuiFormGroup + FormGroup as MuiFormGroup, Autocomplete, Chip } from "@mui/material"; import {LocaleContext} from "shared/locale"; import * as React from "react"; @@ -42,6 +42,8 @@ export default function UserEditView(props) { const {translate: L, requestModules, currentLocale} = useContext(LocaleContext); const [fetchUser, setFetchUser] = useState(!isNewUser); const [user, setUser] = useState(isNewUser ? initialUser : null); + const [groups, setGroups] = useState([]); + const [groupInput, setGroupInput] = useState(""); // ui const [hasChanged, setChanged] = useState(isNewUser); @@ -56,17 +58,27 @@ export default function UserEditView(props) { }); }, [currentLocale]); + const onFetchGroups = useCallback(() => { + api.searchGroups(groupInput, user?.groups?.map(group => group.id)).then((res) => { + if (res.success) { + setGroups(res.groups); + } else { + showDialog(res.msg, L("account.search_groups_error")); + } + }); + }, [api, showDialog, user?.groups, groupInput]); + const onFetchUser = useCallback((force = false) => { if (!isNewUser && (force || fetchUser)) { setFetchUser(false); api.getUser(userId).then((res) => { if (!res.success) { - showDialog(res.msg, L("account.error_user_get")); + showDialog(res.msg, L("account.get_user_error")); if (user === null) { navigate("/admin/users"); } } else { - setUser(res.user); + setUser({...res.user, groups: Object.values(res.user.groups)}); } }); } @@ -77,15 +89,17 @@ export default function UserEditView(props) { setUser({...initialUser}); } else { onFetchUser(true); + setChanged(false); } }, [isNewUser, onFetchUser]); const onSaveUser = useCallback(() => { if (!isSaving) { + let groupIds = user.groups.map(group => group.id); setSaving(true); if (isNewUser) { if (sendInvite) { - api.inviteUser(user.name, user.fullName, user.email).then(res => { + api.inviteUser(user.name, user.fullName, user.email, groupIds).then(res => { setSaving(false); if (res.success) { setChanged(false); @@ -95,7 +109,9 @@ export default function UserEditView(props) { } }); } else { - api.createUser(user.name, user.fullName, user.email, user.password, user.passwordConfirm).then(res => { + api.createUser(user.name, user.fullName, user.email, groupIds, + user.password, user.passwordConfirm + ).then(res => { setSaving(false); if (res.success) { setChanged(false); @@ -108,7 +124,7 @@ export default function UserEditView(props) { } else { api.editUser( userId, user.name, user.email, user.password, - user.groups, user.confirmed, user.active + groupIds, user.confirmed, user.active ).then(res => { setSaving(false); if (res.success) { @@ -120,7 +136,7 @@ export default function UserEditView(props) { } } - }, [isSaving, sendInvite, isNewUser, userId, showDialog]); + }, [isSaving, sendInvite, isNewUser, userId, showDialog, user]); const onChangeValue = useCallback((name, value) => { setUser({...user, [name]: value}); @@ -133,6 +149,10 @@ export default function UserEditView(props) { } }, []); + useEffect(() => { + onFetchGroups(); + }, [groupInput, user?.groups]); + if (user === null) { return } @@ -140,7 +160,7 @@ export default function UserEditView(props) { return Home, User, - {isNewUser ? "New" : "Edit"} + {isNewUser ? L("general.new") : L("general.edit")} ]}> @@ -149,7 +169,7 @@ export default function UserEditView(props) { setUser({...user, name: e.target.value})} /> + onChange={e => onChangeValue("name", e.target.value)} /> @@ -157,18 +177,44 @@ export default function UserEditView(props) { setUser({...user, fullName: e.target.value})} /> + onChange={e => onChangeValue("fullName", e.target.value)} /> {L("account.email")} setUser({...user, email: e.target.value})} /> + onChange={e => onChangeValue("email", e.target.value)} /> + + {L("account.groups")} + group.name} + getOptionKey={group => group.id} + filterOptions={(options) => options} + clearOnBlur={true} + clearOnEscape + freeSolo + multiple + value={user.groups} + inputValue={groupInput} + onChange={(e, v) => onChangeValue("groups", v)} + onInputChange={e => setGroupInput((!e || e.target.value === 0) ? "" : e.target.value) } + renderTags={(values, props) => + values.map((option, index) => { + return + }) + } + renderInput={(params) => setGroupInput("")} />} + /> + { !isNewUser ? <> @@ -178,7 +224,7 @@ export default function UserEditView(props) { value={user.password} type={"password"} placeholder={"(" + L("general.unchanged") + ")"} - onChange={e => setUser({...user, password: e.target.value})} /> + onChange={e => onChangeValue("password", e.target.value)} /> @@ -210,7 +256,7 @@ export default function UserEditView(props) { setUser({...user, password: e.target.value})} /> + onChange={e => onChangeValue("password", e.target.value)} /> @@ -219,7 +265,7 @@ export default function UserEditView(props) { setUser({...user, passwordConfirm: e.target.value})} /> + onChange={e => onChangeValue("passwordConfirm", e.target.value)} /> diff --git a/react/shared/api.js b/react/shared/api.js index 865957f..d236616 100644 --- a/react/shared/api.js +++ b/react/shared/api.js @@ -159,13 +159,13 @@ export default class API { return this.apiCall("user/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder }); } - async inviteUser(username, fullName, email) { - return this.apiCall("user/invite", { username: username, fullName: fullName, email: email }); + async inviteUser(username, fullName, email, groups) { + return this.apiCall("user/invite", { username: username, fullName: fullName, email: email, groups: groups }); } - async createUser(username, fullName, email, password, confirmPassword) { + async createUser(username, fullName, email, groups, password, confirmPassword) { return this.apiCall("user/create", { username: username, email: email, - fullName: fullName, + fullName: fullName, groups: groups, password: password, confirmPassword: confirmPassword }); } @@ -219,6 +219,10 @@ export default class API { return this.apiCall("groups/fetch", { page: pageNum, count: count, orderBy: orderBy, sortOrder: sortOrder }); } + async searchGroups(query = "", exclude = []) { + return this.apiCall("groups/search", { query: query, exclude: exclude }); + } + 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 }); }