diff --git a/src/Routes.jsx b/src/Routes.jsx index 2705f4105..4af988f9d 100644 --- a/src/Routes.jsx +++ b/src/Routes.jsx @@ -94,6 +94,7 @@ const DeleteGroup = React.lazy(() => import("pages/Admin/Group/Delete")); const DeleteUser = React.lazy(() => import("pages/Admin/Users/Delete")); const AddUser = React.lazy(() => import("pages/Admin/Users/Add")); const EditUser = React.lazy(() => import("pages/Admin/Users/Edit")); +const ManageGroup = React.lazy(() => import("pages/Admin/Group/Manage")); const AddLicense = React.lazy(() => import("pages/Admin/License/Create")); const SelectLicense = React.lazy(() => import("pages/Admin/License/SelectLicense") @@ -292,6 +293,11 @@ const Routes = () => { path={routes.admin.group.delete} component={DeleteGroup} /> + { addGroupName: false, }); }; + +// Get all group members +export const getAllGroupMembersApi = (groupId) => { + const url = endpoints.admin.groups.getAllGroupMembers(groupId); + return sendRequest({ + url, + method: "GET", + headers: { + Authorization: getToken(), + }, + }); +}; + +// Remove Group Member +export const removeGroupMemberApi = (groupId, userId) => { + const url = endpoints.admin.groups.removeGroupMember(groupId, userId); + return sendRequest({ + url, + method: "DELETE", + headers: { + Authorization: getToken(), + }, + }); +}; + +// Change user permission +export const changeUserPermissionApi = (groupId, userId, permission) => { + const url = endpoints.admin.groups.changeUserPermission(groupId, userId); + return sendRequest({ + url, + method: "PUT", + headers: { + Authorization: getToken(), + }, + body: { + perm: permission, + }, + }); +}; diff --git a/src/api/groups.test.js b/src/api/groups.test.js index 6074be0ab..f056db65c 100644 --- a/src/api/groups.test.js +++ b/src/api/groups.test.js @@ -15,7 +15,13 @@ import sendRequest from "api/sendRequest"; import endpoints from "constants/endpoints"; -import { createGroupApi, getAllGroupsApi } from "api/groups"; +import { + changeUserPermissionApi, + createGroupApi, + getAllGroupMembersApi, + getAllGroupsApi, + removeGroupMemberApi, +} from "api/groups"; import { getToken } from "shared/authHelper"; jest.mock("api/sendRequest"); @@ -56,4 +62,63 @@ describe("groups", () => { }) ); }); + + test("removeGroupMemberApi", () => { + const groupId = 2; + const userId = 1; + const url = endpoints.admin.groups.removeGroupMember(groupId, userId); + sendRequest.mockImplementation(() => true); + + expect(removeGroupMemberApi(groupId, userId)).toBe(sendRequest({})); + expect(sendRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url, + method: "DELETE", + headers: { + Authorization: getToken(), + }, + }) + ); + }); + + test("getAllGroupMembersApi", () => { + const groupId = 1; + const url = endpoints.admin.groups.getAllGroupMembers(groupId); + sendRequest.mockImplementation(() => true); + + expect(getAllGroupMembersApi(groupId)).toBe(sendRequest({})); + expect(sendRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url, + method: "GET", + headers: { + Authorization: getToken(), + }, + }) + ); + }); + + test("changeUserPermissionApi", () => { + const groupId = 1; + const userId = 1; + const permission = 2; + const url = endpoints.admin.groups.changeUserPermission(groupId, userId); + sendRequest.mockImplementation(() => true); + + expect(changeUserPermissionApi(groupId, userId, permission)).toBe( + sendRequest({}) + ); + expect(sendRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url, + method: "PUT", + headers: { + Authorization: getToken(), + }, + body: { + perm: permission, + }, + }) + ); + }); }); diff --git a/src/components/Admin/ChangePermission.jsx b/src/components/Admin/ChangePermission.jsx new file mode 100644 index 000000000..7bcbce721 --- /dev/null +++ b/src/components/Admin/ChangePermission.jsx @@ -0,0 +1,221 @@ +/* + Copyright (C) 2022 Samuel Dushimimana (dushsam100@gmail.com) + + SPDX-License-Identifier: GPL-2.0 + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + version 2 as published by the Free Software Foundation. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +*/ + +import React, { useEffect, useState } from "react"; + +import { InputContainer } from "components/Widgets"; + +// Required functions for calling APIs +import { changeUserPermission, removeGroupMember } from "services/groups"; + +import PropTypes from "prop-types"; + +// Required constants +import { userPermissions } from "constants/constants"; + +const ChangePermissionContainer = ({ + groupMembers, + noneGroupMembers, + setShowMessage, + setMessage, + currGroup, + handleFetchGroupMembers, +}) => { + const [currUser, setCurrentUser] = useState(null); + const [currNonMember, setCurrentNonMember] = useState(null); + + useEffect(() => { + if (groupMembers.length > 0) { + setCurrentUser({ + user: groupMembers[0].id, + perm: groupMembers[0].group_perm, + }); + } + }, [groupMembers]); + + useEffect(() => { + if (noneGroupMembers.length > 0) { + setCurrentNonMember({ + user: noneGroupMembers[0].id, + perm: -1, + }); + } + }, [noneGroupMembers]); + + const handleChangeCurrUser = async (newUser, isMember = true) => { + if (isMember) { + let perm; + groupMembers.forEach((item) => { + if (item.id === parseInt(newUser, 10)) { + perm = item.group_perm; + } + }); + setCurrentUser({ user: parseInt(newUser, 10), perm }); + } else { + setCurrentNonMember({ user: parseInt(newUser, 10), perm: -1 }); + } + }; + + const handleSetNewPermission = async (newPerm, isMember = true) => { + try { + let res; + + if (parseInt(newPerm, 10) === -1) { + res = await removeGroupMember(currGroup, currUser.user); + } else { + res = await changeUserPermission( + currGroup, + isMember ? currUser.user : currNonMember.user, + parseInt(newPerm, 10) + ); + } + + setShowMessage(true); + setMessage({ + type: "success", + text: res.message, + }); + + handleFetchGroupMembers(currGroup); + } catch (e) { + setMessage({ + type: "danger", + text: e.message, + }); + } finally { + setTimeout(() => { + setShowMessage(false); + }, [3000]); + } + }; + return ( + <> +
+
+ {groupMembers.length > 0 ? ( +
+ + + handleChangeCurrUser(e.target.value)} + /> + + + handleSetNewPermission(e.target.value)} + /> + + + } + /> +
+ ) : ( + <> + )} + {noneGroupMembers.length > 0 ? ( +
+ + + + handleChangeCurrUser(e.target.value, false) + } + /> + + + + handleSetNewPermission(e.target.value, false) + } + /> + + + } + /> +
+ ) : ( + <> + )} +
+
+ + ); +}; + +// eslint-disable-next-line react/prop-types +export const TableFill = ({ ContentFill, title }) => ( + <> +
{title}
+ + + + + + + + {ContentFill} +
UserPermission
+ +); + +TableFill.prototype = { + ContentFill: PropTypes.any, + title: PropTypes.string, +}; + +ChangePermissionContainer.propTypes = { + groupMembers: PropTypes.arrayOf(PropTypes.objectOf(PropTypes.any)).isRequired, + noneGroupMembers: PropTypes.arrayOf(PropTypes.objectOf(PropTypes.any)) + .isRequired, + setMessage: PropTypes.func, + currGroup: PropTypes.number, + handleFetchGroupMembers: PropTypes.func, + setShowMessage: PropTypes.func, +}; + +export default ChangePermissionContainer; diff --git a/src/components/Header/index.jsx b/src/components/Header/index.jsx index bc6b0f47c..4d42c3eb7 100644 --- a/src/components/Header/index.jsx +++ b/src/components/Header/index.jsx @@ -258,9 +258,9 @@ const Header = () => { - Delete Group + Manage Group Users diff --git a/src/components/Widgets/Input/index.jsx b/src/components/Widgets/Input/index.jsx index 5a9722626..50e421463 100644 --- a/src/components/Widgets/Input/index.jsx +++ b/src/components/Widgets/Input/index.jsx @@ -26,6 +26,7 @@ const InputContainer = ({ id, className, onChange, + defaultValue = null, children, checked = false, placeholder = null, @@ -70,6 +71,7 @@ const InputContainer = ({ className ? `mr-2 form-control ${className}` : `mr-2 form-control` } value={value} + defaultValue={defaultValue} onChange={onChange} multiple={multiple && multiple} size={multiple ? "15" : ""} @@ -125,6 +127,7 @@ InputContainer.propTypes = { onChange: PropTypes.func, checked: PropTypes.bool, disabled: PropTypes.bool, + defaultValue: PropTypes.string, children: PropTypes.node, options: PropTypes.arrayOf( PropTypes.shape({ diff --git a/src/constants/constants.js b/src/constants/constants.js index 7fe2a369d..82917e6d4 100644 --- a/src/constants/constants.js +++ b/src/constants/constants.js @@ -358,3 +358,23 @@ export const agents = { reso: "REUSE.Software Analysis (forces *Ojo License Analysis*)", heritage: "Software Heritage Analysis", }; + +// eslint-disable-next-line camelcase +export const userPermissions = [ + { + id: -1, + name: "None", + }, + { + id: 0, + name: "User", + }, + { + id: 1, + name: "Admin", + }, + { + id: 2, + name: "Advisor", + }, +]; diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 8728a0fd3..9ea28132e 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -78,6 +78,11 @@ const endpoints = { getAll: () => `${apiUrl}/groups`, getAllDeletable: () => `${apiUrl}/groups/deletable`, delete: (groupId) => `${apiUrl}/groups/${groupId}`, + getAllGroupMembers: (groupId) => `${apiUrl}/groups/${groupId}/members`, + changeUserPermission: (groupId, userId) => + `${apiUrl}/groups/${groupId}/user/${userId}`, + removeGroupMember: (groupId, userId) => + `${apiUrl}/groups/${groupId}/user/${userId}`, }, maintenance: { create: () => `${apiUrl}/maintenance`, diff --git a/src/constants/routes.js b/src/constants/routes.js index 832858e97..59fe2587d 100644 --- a/src/constants/routes.js +++ b/src/constants/routes.js @@ -67,6 +67,7 @@ const routes = { group: { create: "/admin/group/create", delete: "/admin/group/delete", + manageGroup: "/admin/group/manage", }, users: { add: "/admin/users/add", diff --git a/src/pages/Admin/Group/Manage/index.jsx b/src/pages/Admin/Group/Manage/index.jsx new file mode 100644 index 000000000..6f8ad4b2d --- /dev/null +++ b/src/pages/Admin/Group/Manage/index.jsx @@ -0,0 +1,177 @@ +/* + Copyright (C) 2022 Samuel Dushimimana (dushsam100@gmail.com) + + SPDX-License-Identifier: GPL-2.0 + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + version 2 as published by the Free Software Foundation. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +*/ + +import React, { useEffect, useState } from "react"; + +// Title +import Title from "components/Title"; + +// Widgets +import { Alert, InputContainer } from "components/Widgets"; + +// Required functions for calling APIs +import { fetchAllGroupMembers, fetchAllGroups } from "services/groups"; + +// Required page components +import ChangePermissionContainer from "components/Admin/ChangePermission"; +import { getAllUsers } from "../../../../services/users"; + +const ManageGroup = () => { + const initialMessage = { + type: "success", + text: "", + }; + + const [currGroup, setCurrentGroup] = useState(null); + const [groupMembers, setGroupMembers] = useState([]); + const [noneGroupMembers, setNoneGroupMembers] = useState([]); + + const [groups, setGroups] = useState([]); + + const [showMessage, setShowMessage] = useState(false); + const [message, setMessage] = useState(initialMessage); + + const handleSetAllUsers = async (groupId) => { + try { + const members = await fetchAllGroupMembers(groupId); + const memberUsers = []; + + for (let i = 0; i < members.length; i++) { + memberUsers.push({ + id: members[i].user.id, + group_perm: members[i].group_perm, + name: members[i].user.name, + }); + } + + setGroupMembers(memberUsers); + } catch (e) { + setMessage({ + type: "danger", + text: e.message, + }); + } + }; + + useEffect(async () => { + const users = await getAllUsers(); + + const noneMembers = []; + + for (let i = 0; i < users.length; i++) { + let isMember = false; + for (let j = 0; j < groupMembers.length; j++) { + if (users[i].id === groupMembers[j].id) { + isMember = true; + break; + } + } + if (!isMember) { + noneMembers.push(users[i]); + } + } + setNoneGroupMembers(noneMembers); + }, [groupMembers]); + + useEffect(async () => { + try { + const res = await fetchAllGroups(); + setCurrentGroup(res[0].id); + setGroups(res); + await handleSetAllUsers(res[0].id); + } catch (e) { + setMessage({ + type: "danger", + text: e.message, + }); + } + }, []); + + const handleFetchGroupMembers = async (groupId) => { + try { + await handleSetAllUsers(groupId); + } catch (error) { + setMessage({ + type: "danger", + text: error.message, + }); + } + }; + + const handleGroupChange = async (e) => { + setCurrentGroup(e.target.value); + await handleSetAllUsers(e.target.value); + }; + + return ( + <> + + <div className="main-container my-3"> + {showMessage && ( + <Alert + type={message.type} + setShow={setShowMessage} + message={message.text} + /> + )} + <h1 className="font-size-main-heading">Manage Group Users</h1> + <br /> + <div className="row"> + <div className="col-12 col-lg-8"> + <form> + <InputContainer + type="select" + name="name" + options={groups} + id="select-tag" + property="name" + onChange={(e) => handleGroupChange(e)} + value={currGroup} + > + Select group to manage: + </InputContainer> + </form> + {noneGroupMembers && groupMembers && ( + <ChangePermissionContainer + currGroup={currGroup} + groupMembers={groupMembers} + noneGroupMembers={noneGroupMembers} + setShowMessage={setShowMessage} + setMessage={setMessage} + handleFetchGroupMembers={handleFetchGroupMembers} + /> + )} + </div> + <div className="col-10 mt-4"> + <p> + All user permissions take place immediately when a value is + changed. There is no submit button. Add new users on the last + line. + </p> + <p className="font-weight-bold"> + Note: By removing users, you may loose access to uploads, that are + uploaded by them. + </p> + </div> + </div> + </div> + </> + ); +}; + +export default ManageGroup; diff --git a/src/services/groups.js b/src/services/groups.js index d44995940..d66438f4c 100644 --- a/src/services/groups.js +++ b/src/services/groups.js @@ -22,6 +22,9 @@ import { createGroupApi, deleteGroupApi, getAllDeletableGroupsApi, + getAllGroupMembersApi, + changeUserPermissionApi, + removeGroupMemberApi, } from "api/groups"; import { setLocalStorage, getLocalStorage } from "shared/storageHelper"; @@ -57,3 +60,24 @@ export const fetchAllDeletableGroups = () => { return res; }); }; + +// Get all group members +export const fetchAllGroupMembers = (groupId) => { + return getAllGroupMembersApi(groupId).then((res) => { + return res; + }); +}; + +// Change user permission +export const changeUserPermission = (groupId, userId, permission) => { + return changeUserPermissionApi(groupId, userId, permission).then((res) => { + return res; + }); +}; + +// Remove group member +export const removeGroupMember = (groupId, userId) => { + return removeGroupMemberApi(groupId, userId).then((res) => { + return res; + }); +};