diff --git a/client/src/Components/Inputs/Search/index.jsx b/client/src/Components/Inputs/Search/index.jsx index 9f033cd52..c3008886c 100644 --- a/client/src/Components/Inputs/Search/index.jsx +++ b/client/src/Components/Inputs/Search/index.jsx @@ -77,6 +77,7 @@ const Search = ({ labelVariant, labelSx = {}, unit = "option", + maxWidth = "100%", }) => { const theme = useTheme(); const { t } = useTranslation(); diff --git a/client/src/Components/Tab/index.jsx b/client/src/Components/Tab/index.jsx index 72709cc00..7609904d9 100644 --- a/client/src/Components/Tab/index.jsx +++ b/client/src/Components/Tab/index.jsx @@ -16,6 +16,7 @@ const CustomTabList = ({ value, onChange, children, ...props }) => { return ( { return isAdmin; }; -export { useIsAdmin }; +const useIsSuperAdmin = () => { + const { user } = useSelector((state) => state.auth); + const isSuperAdmin = user?.role?.includes("superadmin"); + return isSuperAdmin; +}; + +export { useIsAdmin, useIsSuperAdmin }; diff --git a/client/src/Hooks/userHooks.js b/client/src/Hooks/userHooks.js new file mode 100644 index 000000000..8be32a082 --- /dev/null +++ b/client/src/Hooks/userHooks.js @@ -0,0 +1,61 @@ +import { useEffect, useState, useCallback } from "react"; +import { networkService } from "../main"; +import { createToast } from "../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; + +export const useGetUser = (userId) => { + const [user, setUser] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchUser = useCallback(async () => { + try { + setIsLoading(true); + const response = await networkService.getUserById({ userId }); + setUser(response?.data?.data); + } catch (error) { + createToast({ + body: error.message, + }); + setError(error); + } finally { + setIsLoading(false); + } + }, [userId]); + + useEffect(() => { + if (userId) { + fetchUser(); + } + }, [userId, fetchUser]); + + return [user, isLoading, error]; +}; + +export const useEditUser = (userId) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { t } = useTranslation(); + + const editUser = useCallback( + async (user) => { + try { + setIsLoading(true); + await networkService.editUser({ userId, user }); + createToast({ + body: t("editUserPage.toast.successUserUpdate"), + }); + } catch (error) { + createToast({ + body: error.message, + }); + setError(error); + } finally { + setIsLoading(false); + } + }, + [userId] + ); + + return [editUser, isLoading, error]; +}; diff --git a/client/src/Pages/Account/EditUser/hooks/editUser.js b/client/src/Pages/Account/EditUser/hooks/editUser.js new file mode 100644 index 000000000..2ffa914a0 --- /dev/null +++ b/client/src/Pages/Account/EditUser/hooks/editUser.js @@ -0,0 +1,102 @@ +import { useState, useEffect } from "react"; +import { ROLES } from "../../../../Utils/roleUtils"; +import { editUserValidation } from "../../../../Validation/validation"; +import Joi from "joi"; +import { createToast } from "../../../../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; +export const useEditUserForm = (user) => { + const [searchInput, setSearchInput] = useState(""); + const [form, setForm] = useState({ + firstName: "", + lastName: "", + email: "", + role: [], + }); + + // Effect to set user + useEffect(() => { + if (user) { + setForm({ + firstName: user?.firstName, + lastName: user?.lastName, + email: user?.email, + role: user?.role, + }); + } + }, [user]); + + const handleRoleChange = (value) => { + const hasSuperAdmin = form.role.includes(ROLES.SUPERADMIN); + const newRoles = value.map((item) => item.role); + if (hasSuperAdmin && !newRoles.includes(ROLES.SUPERADMIN)) { + newRoles.push(ROLES.SUPERADMIN); + } + + setForm({ + ...form, + role: newRoles, + }); + }; + + const handleDeleteRole = (role) => { + if (role === ROLES.SUPERADMIN) return; + setForm({ ...form, role: form?.role?.filter((r) => r !== role) }); + }; + + const handleSearchInput = (value) => { + setSearchInput(value); + }; + + return [ + form, + setForm, + handleRoleChange, + handleDeleteRole, + searchInput, + handleSearchInput, + ]; +}; + +export const useValidateEditUserForm = () => { + const [errors, setErrors] = useState({}); + const { t } = useTranslation(); + const validateForm = (form) => { + const { error } = editUserValidation.validate(form, { + abortEarly: false, + }); + const errs = error?.details; + const errsObj = errs?.reduce((acc, curr) => { + acc[curr.path[0]] = curr.message; + return acc; + }, {}); + setErrors(errsObj); + + if (errs?.length > 0) { + createToast({ + variant: "warning", + hasIcon: true, + title: t("editUserPage.toast.validationErrors"), + body: errs.map((err) => t(err.message)), + }); + return false; + } + return true; + }; + + const validateField = (name, value) => { + const fieldSchema = Joi.object({ + [name]: editUserValidation.extract(name), + }); + + const { error } = fieldSchema.validate({ [name]: value }); + + setErrors((prev) => { + const prevErrors = { ...prev }; + if (error) prevErrors[name] = error.details[0].message; + else delete prevErrors[name]; + return prevErrors; + }); + }; + + return [errors, validateForm, validateField]; +}; diff --git a/client/src/Pages/Account/EditUser/index.jsx b/client/src/Pages/Account/EditUser/index.jsx new file mode 100644 index 000000000..e1eeead9d --- /dev/null +++ b/client/src/Pages/Account/EditUser/index.jsx @@ -0,0 +1,122 @@ +// Components +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Breadcrumbs from "../../../Components/Breadcrumbs"; +import TextInput from "../../../Components/Inputs/TextInput"; +import Search from "../../../Components/Inputs/Search"; +import Button from "@mui/material/Button"; +import RoleTable from "../components/RoleTable"; + +// Utils +import { useParams } from "react-router-dom"; +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import { useGetUser, useEditUser } from "../../../Hooks/userHooks"; +import { EDITABLE_ROLES, ROLES } from "../../../Utils/roleUtils"; +import { useEditUserForm, useValidateEditUserForm } from "./hooks/editUser"; + +const EditUser = () => { + const { userId } = useParams(); + const theme = useTheme(); + const { t } = useTranslation(); + const BREADCRUMBS = [ + { name: t("menu.team"), path: "/account/team" }, + { name: t("editUserPage.title"), path: "" }, + ]; + + const [user, isLoading, error] = useGetUser(userId); + const [editUser, isSaving, saveError] = useEditUser(userId); + const [ + form, + setForm, + handleRoleChange, + handleDeleteRole, + searchInput, + handleSearchInput, + ] = useEditUserForm(user); + const [errors, validateForm, validateField] = useValidateEditUserForm(); + + const onChange = (e) => { + const name = e.target.name; + const value = e.target.value; + validateField(name, value); + setForm({ ...form, [name]: value }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const valid = validateForm(form); + if (valid) { + editUser(form); + } + }; + + return ( + + + {t("editUserPage.title")} + + + + + role !== ROLES.SUPERADMIN) + .map((role) => ({ role, _id: role })) || [] + } + options={EDITABLE_ROLES} + multiple={true} + handleChange={handleRoleChange} + /> + + + + + + + ); +}; + +export default EditUser; diff --git a/client/src/Components/TabPanels/Account/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx similarity index 97% rename from client/src/Components/TabPanels/Account/PasswordPanel.jsx rename to client/src/Pages/Account/components/PasswordPanel.jsx index 81ce93946..dabd67706 100644 --- a/client/src/Components/TabPanels/Account/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -2,10 +2,10 @@ import TabPanel from "@mui/lab/TabPanel"; import { useState } from "react"; import { useTheme } from "@emotion/react"; import { Box, Stack, Typography, Button } from "@mui/material"; -import { PasswordEndAdornment } from "../../Inputs/TextInput/Adornments"; -import TextInput from "../../Inputs/TextInput"; +import { PasswordEndAdornment } from "../../../Components/Inputs/TextInput/Adornments"; +import TextInput from "../../../Components/Inputs/TextInput"; import { newOrChangedCredentials } from "../../../Validation/validation"; -import Alert from "../../Alert"; +import Alert from "../../../Components/Alert"; import { update } from "../../../Features/Auth/authSlice"; import { useDispatch, useSelector } from "react-redux"; import { createToast } from "../../../Utils/toastUtils"; diff --git a/client/src/Components/TabPanels/Account/ProfilePanel.jsx b/client/src/Pages/Account/components/ProfilePanel.jsx similarity index 97% rename from client/src/Components/TabPanels/Account/ProfilePanel.jsx rename to client/src/Pages/Account/components/ProfilePanel.jsx index 9b209bf3e..7c73e7aa3 100644 --- a/client/src/Components/TabPanels/Account/ProfilePanel.jsx +++ b/client/src/Pages/Account/components/ProfilePanel.jsx @@ -2,16 +2,16 @@ import { useTheme } from "@emotion/react"; import { useState } from "react"; import TabPanel from "@mui/lab/TabPanel"; import { Box, Button, Divider, Stack, Typography } from "@mui/material"; -import Avatar from "../../Avatar"; -import TextInput from "../../Inputs/TextInput"; -import ImageUpload from "../../Inputs/ImageUpload"; +import Avatar from "../../../Components/Avatar"; +import TextInput from "../../../Components/Inputs/TextInput"; +import ImageUpload from "../../../Components/Inputs/ImageUpload"; import { newOrChangedCredentials } from "../../../Validation/validation"; import { useDispatch, useSelector } from "react-redux"; import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice"; import { createToast } from "../../../Utils/toastUtils"; import { logger } from "../../../Utils/Logger"; -import { GenericDialog } from "../../Dialog/genericDialog"; -import Dialog from "../../Dialog"; +import { GenericDialog } from "../../../Components/Dialog/genericDialog"; +import Dialog from "../../../Components/Dialog"; import { useTranslation } from "react-i18next"; /** @@ -191,7 +191,6 @@ const ProfilePanel = () => { > { + const { t } = useTranslation(); + const HEADERS = [ + { + id: "name", + content: {t("editUserPage.table.roleHeader")}, + render: (row) => { + return row; + }, + }, + { + id: "delete", + content: {t("editUserPage.table.actionHeader")}, + render: (row) => { + if (row === ROLES.SUPERADMIN) return null; + return ( + { + handleDeleteRole(row); + }} + sx={{ cursor: "pointer" }} + /> + ); + }, + }, + ]; + return ( + + ); +}; + +export default RoleTable; diff --git a/client/src/Components/TabPanels/Account/TeamPanel.jsx b/client/src/Pages/Account/components/TeamPanel.jsx similarity index 92% rename from client/src/Components/TabPanels/Account/TeamPanel.jsx rename to client/src/Pages/Account/components/TeamPanel.jsx index 45578cc6c..95ece219f 100644 --- a/client/src/Components/TabPanels/Account/TeamPanel.jsx +++ b/client/src/Pages/Account/components/TeamPanel.jsx @@ -3,14 +3,16 @@ import TabPanel from "@mui/lab/TabPanel"; import { Button, ButtonGroup, Stack, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import TextInput from "../../Inputs/TextInput"; +import TextInput from "../../../Components/Inputs/TextInput"; import { newOrChangedCredentials } from "../../../Validation/validation"; import { networkService } from "../../../main"; import { createToast } from "../../../Utils/toastUtils"; -import Select from "../../Inputs/Select"; -import { GenericDialog } from "../../Dialog/genericDialog"; -import DataTable from "../../Table/"; +import Select from "../../../Components/Inputs/Select"; +import { GenericDialog } from "../../../Components/Dialog/genericDialog"; +import DataTable from "../../../Components/Table"; import { useGetInviteToken } from "../../../Hooks/inviteHooks"; +import { useNavigate } from "react-router-dom"; +import { useIsSuperAdmin } from "../../../Hooks/useIsAdmin"; /** * TeamPanel component manages the organization and team members, * providing functionalities like renaming the organization, managing team members, @@ -34,8 +36,9 @@ const TeamPanel = () => { const [isDisabled, setIsDisabled] = useState(true); const [errors, setErrors] = useState({}); const [isSendingInvite, setIsSendingInvite] = useState(false); - + const navigate = useNavigate(); const [getInviteToken, clearToken, isLoading, error, token] = useGetInviteToken(); + const isSuperAdmin = useIsSuperAdmin(); const headers = [ { @@ -184,7 +187,6 @@ const TeamPanel = () => { return ( { { + if (isSuperAdmin) { + navigate(`/account/team/${row.id}`); + } + }, + }} /> diff --git a/client/src/Pages/Account/index.css b/client/src/Pages/Account/index.css deleted file mode 100644 index 41e25fffa..000000000 --- a/client/src/Pages/Account/index.css +++ /dev/null @@ -1,38 +0,0 @@ -.account h1.MuiTypography-root, -.account p.MuiTypography-root, -.account input, -.account button, -.account td, -.account .MuiSelect-select { - font-size: var(--env-var-font-size-medium); -} -.account h1.MuiTypography-root { - font-weight: 600; -} -.account .MuiTabPanel-root { - padding: 0; - margin-top: 50px; -} -.account button:not(.MuiIconButton-root) { - min-height: 34px; -} -.account .field { - flex: 1; -} -#modal-delete-account, -#modal-edit-org-name, -#modal-invite-member { - font-size: var(--env-var-font-size-large); -} - -.account .MuiStack-root:has(span.MuiTypography-root.input-error) { - position: relative; -} -.account:not(:has(#modal-invite-member)) span.MuiTypography-root.input-error { - position: absolute; - top: 100%; -} - -.account .MuiTableBody-root .MuiTableCell-root { - padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2); -} diff --git a/client/src/Pages/Account/index.jsx b/client/src/Pages/Account/index.jsx index d8672f51c..37c64aca6 100644 --- a/client/src/Pages/Account/index.jsx +++ b/client/src/Pages/Account/index.jsx @@ -5,11 +5,10 @@ import { useSelector } from "react-redux"; import { Box, Tab, useTheme } from "@mui/material"; import CustomTabList from "../../Components/Tab"; import TabContext from "@mui/lab/TabContext"; -import ProfilePanel from "../../Components/TabPanels/Account/ProfilePanel"; -import PasswordPanel from "../../Components/TabPanels/Account/PasswordPanel"; -import TeamPanel from "../../Components/TabPanels/Account/TeamPanel"; +import ProfilePanel from "./components/ProfilePanel"; +import PasswordPanel from "./components/PasswordPanel"; +import TeamPanel from "./components/TeamPanel"; import { useTranslation } from "react-i18next"; -import "./index.css"; /** * Account component renders a settings page with tabs for Profile, Password, and Team settings. @@ -33,7 +32,9 @@ const Account = ({ open = "profile" }) => { { name: t("menu.password"), value: "password" }, { name: t("menu.team"), value: "team" }, ]; + const hideTeams = !requiredRoles.some((role) => user.role.includes(role)); + if (hideTeams) { tabList = [ { name: t("menu.profile"), value: "profile" }, @@ -63,7 +64,6 @@ const Account = ({ open = "profile" }) => { return ( diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 3db8b772d..6de07a2de 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -42,6 +42,7 @@ import CreateNotifications from "../Pages/Notifications/create"; // Settings import Account from "../Pages/Account"; +import EditUser from "../Pages/Account/EditUser"; import Settings from "../Pages/Settings"; import Maintenance from "../Pages/Maintenance"; @@ -187,6 +188,14 @@ const Routes = () => { path="account/team" element={} /> + + + + } + /> { + await getUserByIdParamValidation.validateAsync(req.params); + const userId = req?.params?.userId; + const roles = req?.user?.role; + + if (!userId) { + throw new Error("No user ID in request"); + } + + if (!roles || roles.length === 0) { + throw new Error("No roles in request"); + } + + const user = await this.db.getUserById(roles, userId); + + return res.success({ msg: "ok", data: user }); + }, + SERVICE_NAME, + "getUserById" + ); + + editUserById = asyncHandler( + async (req, res, next) => { + const roles = req?.user?.role; + if (!roles.includes("superadmin")) { + throw createError("Unauthorized", 403); + } + + const userId = req.params.userId; + const user = { ...req.body }; + + await editUserByIdParamValidation.validateAsync(req.params); + // If this is superadmin self edit, allow "superadmin" role + if (userId === req.user._id) { + await editSuperadminUserByIdBodyValidation.validateAsync(req.body); + } else { + await editUserByIdBodyValidation.validateAsync(req.body); + } + + await this.db.editUserById(userId, user); + return res.success({ msg: "ok" }); + }, + SERVICE_NAME, + "editUserById" + ); } export default AuthController; diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js index cd609bb5d..311a345e1 100755 --- a/server/db/mongo/modules/userModule.js +++ b/server/db/mongo/modules/userModule.js @@ -209,4 +209,33 @@ const logoutUser = async (userId) => { } }; -export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser }; +const getUserById = async (roles, userId) => { + try { + if (!roles.includes("superadmin")) { + throw new Error("User is not a superadmin"); + } + + const user = await UserModel.findById(userId).select("-password").select("-profileImage"); + if (!user) { + throw new Error("User not found"); + } + + return user; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getUserById"; + throw error; + } +}; + +const editUserById = async (userId, user) => { + try { + await UserModel.findByIdAndUpdate(userId, user, { new: true }).select("-password").select("-profileImage"); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "editUserById"; + throw error; + } +}; + +export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser, getUserById, editUserById }; diff --git a/server/routes/authRoute.js b/server/routes/authRoute.js index 6d8af52f8..e790c60b0 100755 --- a/server/routes/authRoute.js +++ b/server/routes/authRoute.js @@ -25,6 +25,8 @@ class AuthRoutes { this.router.get("/users/superadmin", this.authController.checkSuperadminExists); this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); + this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById); + this.router.put("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); this.router.put("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser); this.router.delete("/user", verifyJWT, this.authController.deleteUser); diff --git a/server/validation/joi.js b/server/validation/joi.js index b33283d98..619a30ea9 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -1,4 +1,5 @@ import joi from "joi"; +import { ROLES, VALID_ROLES } from "../../client/src/Utils/roleUtils.js"; //**************************************** // Custom Validators @@ -652,6 +653,36 @@ const sendTestEmailBodyValidation = joi.object({ systemEmailTLSServername: joi.string().allow("").optional(), }); +const getUserByIdParamValidation = joi.object({ + userId: joi.string().required(), +}); + +const editUserByIdParamValidation = joi.object({ + userId: joi.string().required(), +}); + +const editUserByIdBodyValidation = joi.object({ + firstName: nameValidation.required(), + lastName: nameValidation.required(), + email: joi.string().email().required(), + role: joi + .array() + .items(joi.string().valid(...VALID_ROLES)) + .min(1) + .required(), +}); + +const editSuperadminUserByIdBodyValidation = joi.object({ + firstName: nameValidation.required(), + lastName: nameValidation.required(), + email: joi.string().email().required(), + role: joi + .array() + .items(joi.string().valid(...VALID_ROLES, ROLES.SUPERADMIN)) + .min(1) + .required(), +}); + export { roleValidatior, loginValidation, @@ -719,4 +750,8 @@ export { webhookConfigValidation, createAnnouncementValidation, sendTestEmailBodyValidation, + getUserByIdParamValidation, + editUserByIdParamValidation, + editUserByIdBodyValidation, + editSuperadminUserByIdBodyValidation, };