From 4f68a76674f94ce34f19511f4ac33ae20e8b97da Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 12:27:06 -0700 Subject: [PATCH 1/9] implement frontend of user edit --- client/src/Components/Inputs/Search/index.jsx | 1 + client/src/Components/Tab/index.jsx | 1 + client/src/Hooks/useIsAdmin.js | 8 +- client/src/Hooks/userHooks.js | 61 +++++++++ .../Pages/Account/EditUser/hooks/editUser.js | 101 +++++++++++++++ client/src/Pages/Account/EditUser/index.jsx | 122 ++++++++++++++++++ .../Account/components}/PasswordPanel.jsx | 6 +- .../Account/components}/ProfilePanel.jsx | 11 +- .../Account/components/RoleTable/index.jsx | 42 ++++++ .../Account/components}/TeamPanel.jsx | 26 +++- client/src/Pages/Account/index.css | 38 ------ client/src/Pages/Account/index.jsx | 10 +- client/src/Routes/index.jsx | 9 ++ client/src/Utils/NetworkService.js | 18 +++ client/src/Utils/roleUtils.js | 11 ++ client/src/Validation/validation.js | 20 +++ client/src/locales/en.json | 24 ++++ 17 files changed, 449 insertions(+), 60 deletions(-) create mode 100644 client/src/Hooks/userHooks.js create mode 100644 client/src/Pages/Account/EditUser/hooks/editUser.js create mode 100644 client/src/Pages/Account/EditUser/index.jsx rename client/src/{Components/TabPanels/Account => Pages/Account/components}/PasswordPanel.jsx (97%) rename client/src/{Components/TabPanels/Account => Pages/Account/components}/ProfilePanel.jsx (97%) create mode 100644 client/src/Pages/Account/components/RoleTable/index.jsx rename client/src/{Components/TabPanels/Account => Pages/Account/components}/TeamPanel.jsx (92%) delete mode 100644 client/src/Pages/Account/index.css create mode 100644 client/src/Utils/roleUtils.js 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..74914ee07 --- /dev/null +++ b/client/src/Pages/Account/EditUser/hooks/editUser.js @@ -0,0 +1,101 @@ +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) => { + 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={} /> + + + + } + /> Date: Tue, 22 Jul 2025 12:27:17 -0700 Subject: [PATCH 2/9] add edit user routes --- server/routes/authRoute.js | 2 ++ 1 file changed, 2 insertions(+) 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); From e047309812d95b6eeab0bc8bdc59c1a4c10387a0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 12:27:33 -0700 Subject: [PATCH 3/9] add controller methods --- server/controllers/authController.js | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index ace63b662..88580d147 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -393,6 +393,44 @@ class AuthController { SERVICE_NAME, "getAllUsers" ); + + getUserById = asyncHandler( + async (req, res, next) => { + 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 userId = req.params.userId; + const user = req.body; + + const roles = req?.user?.role; + if (!roles.includes("superadmin")) { + throw createError("Unauthorized", 403); + } + + await this.db.editUserById(userId, user); + return res.success({ msg: "ok" }); + }, + SERVICE_NAME, + "editUserById" + ); } export default AuthController; From c4d42dd9e3c3cce2305127bf9506e585b9256a23 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 12:27:41 -0700 Subject: [PATCH 4/9] add db --- server/db/mongo/modules/userModule.js | 31 ++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 }; From 73f2f29c2aa73fac349ea5c933e6e620a6ee179d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 13:26:01 -0700 Subject: [PATCH 5/9] add valid roles --- client/src/Utils/roleUtils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/Utils/roleUtils.js b/client/src/Utils/roleUtils.js index 2e8eea639..f359246b2 100644 --- a/client/src/Utils/roleUtils.js +++ b/client/src/Utils/roleUtils.js @@ -5,6 +5,8 @@ export const ROLES = { DEMO: "demo", }; +export const VALID_ROLES = [ROLES.ADMIN, ROLES.USER, ROLES.DEMO]; + export const EDITABLE_ROLES = [ { role: ROLES.ADMIN, _id: ROLES.ADMIN }, { role: ROLES.USER, _id: ROLES.USER }, From 22826afd211978445ba70b5762826fc304d461bb Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 13:26:45 -0700 Subject: [PATCH 6/9] apply validation --- server/controllers/authController.js | 19 ++++++++++-- server/validation/joi.js | 44 +++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 88580d147..208b981fc 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -5,6 +5,10 @@ import { recoveryValidation, recoveryTokenValidation, newPasswordValidation, + getUserByIdParamValidation, + editUserByIdParamValidation, + editUserByIdBodyValidation, + editSuperadminUserByIdBodyValidation, } from "../validation/joi.js"; import jwt from "jsonwebtoken"; import { getTokenFromHeaders } from "../utils/utils.js"; @@ -396,6 +400,7 @@ class AuthController { getUserById = asyncHandler( async (req, res, next) => { + await getUserByIdParamValidation.validateAsync(req.params); const userId = req?.params?.userId; const roles = req?.user?.role; @@ -417,14 +422,22 @@ class AuthController { editUserById = asyncHandler( async (req, res, next) => { - const userId = req.params.userId; - const user = req.body; - 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" }); }, diff --git a/server/validation/joi.js b/server/validation/joi.js index 57c8b7350..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 @@ -164,11 +165,7 @@ const createMonitorBodyValidation = joi.object({ .custom((value, helpers) => { // 1. Standard URLs: must have protocol and pass canParse() if (/^(https?:\/\/)/.test(value)) { - if ( - typeof URL !== "undefined" && - typeof URL.canParse === "function" && - URL.canParse(value) - ) { + if (typeof URL !== "undefined" && typeof URL.canParse === "function" && URL.canParse(value)) { return value; } // else, it's a malformed URL with protocol @@ -197,8 +194,7 @@ const createMonitorBodyValidation = joi.object({ .messages({ "string.empty": "This field is required.", "string.uri": "The URL you provided is not valid.", - "string.invalidUrl": - "Please enter a valid URL, hostname, or container name (with optional port).", + "string.invalidUrl": "Please enter a valid URL, hostname, or container name (with optional port).", }), ignoreTlsErrors: joi.boolean().default(false), port: joi.number(), @@ -657,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, @@ -724,4 +750,8 @@ export { webhookConfigValidation, createAnnouncementValidation, sendTestEmailBodyValidation, + getUserByIdParamValidation, + editUserByIdParamValidation, + editUserByIdBodyValidation, + editSuperadminUserByIdBodyValidation, }; From f74289fe49e9b79f5f301c2e4a3fff0c9eef55de Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 13:32:26 -0700 Subject: [PATCH 7/9] format --- client/src/Components/LanguageSelector.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx index 57d102a9e..56a9a898d 100644 --- a/client/src/Components/LanguageSelector.jsx +++ b/client/src/Components/LanguageSelector.jsx @@ -6,7 +6,6 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { setLanguage } from "../Features/UI/uiSlice"; - const langMap = { cs: "cz", ja: "jp", From 457408006b9ce329f80e15e65562fabd19c3d50b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 13:33:11 -0700 Subject: [PATCH 8/9] sort en.json --- client/src/locales/en.json | 137 ++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 69 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a7785cbc5..1c948cd64 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -217,8 +217,8 @@ "checkFormError": "Please check the form for errors.", "checkFrequency": "Check frequency", "checkHooks": { - "failureResolveOne": "Failed to resolve incident.", - "failureResolveAll": "Failed to resolve all incidents." + "failureResolveAll": "Failed to resolve all incidents.", + "failureResolveOne": "Failed to resolve incident." }, "checkingEvery": "Checking every", "city": "CITY", @@ -234,9 +234,6 @@ "unknownError": "Unknown error" } }, - "general": { - "noOptionsFound": "No {{unit}} found" - }, "commonSave": "Save", "commonSaving": "Saving...", "companyName": "Company name", @@ -256,6 +253,8 @@ "createMonitor": "Create monitor", "createNew": "Create new", "createNotifications": { + "dialogDeleteConfirm": "Delete", + "dialogDeleteTitle": "Are you sure you want to delete this notification?", "discordSettings": { "description": "Configure your Discord webhook here", "title": "Discord", @@ -286,6 +285,7 @@ "webhookLabel": "Slack webhook URL", "webhookPlaceholder": "https://hooks.slack.com/services/..." }, + "testNotification": "Test notification", "title": "Create notification channel", "typeSettings": { "description": "Select the type of notification channel you want to create.", @@ -297,10 +297,7 @@ "title": "Webhook", "webhookLabel": "Webhook URL", "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "Test notification", - "dialogDeleteTitle": "Are you sure you want to delete this notification?", - "dialogDeleteConfirm": "Delete" + } }, "createYour": "Create your", "date&Time": "Date & Time", @@ -310,6 +307,27 @@ "deleteStatusPage": "Do you want to delete this status page?", "deleteStatusPageConfirm": "Yes, delete status page", "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", + "diagnosticsPage": { + "diagnosticDescription": "System diagnostics", + "gauges": { + "heapAllocationSubtitle": "% of available memory", + "heapAllocationTitle": "Heap allocation", + "heapUsageSubtitle": "% of available memory", + "heapUsageTitle": "Heap usage", + "heapUtilizationSubtitle": "% of allocated", + "heapUtilizationTitle": "Heap utilization", + "instantCpuUsageSubtitle": "% of 1s used by CPU", + "instantCpuUsageTitle": "Instant CPU usage" + }, + "stats": { + "eventLoopDelayTitle": "Event loop delay", + "osMemoryLimitTitle": "OS Memory Limit", + "totalHeapSizeTitle": "Total heap size", + "uptimeTitle": "Uptime", + "usedHeapSizeTitle": "Used heap size" + }, + "statsDescription": "System statistics" + }, "disk": "Disk", "diskUsage": "Disk Usage", "displayName": "Display name", @@ -360,6 +378,24 @@ "duration": "Duration", "edit": "Edit", "editMaintenance": "Edit maintenance", + "editUserPage": { + "form": { + "email": "Email", + "firstName": "First name", + "lastName": "Last name", + "role": "Roles", + "save": "Save" + }, + "table": { + "actionHeader": "Action", + "roleHeader": "Role" + }, + "title": "Edit user", + "toast": { + "successUserUpdate": "User updated successfully", + "validationErrors": "Validation errors" + } + }, "editing": "Editing...", "emailSent": "Email sent successfully", "errorInvalidFieldId": "Invalid field ID provided", @@ -390,6 +426,9 @@ "friendlyNameInput": "Friendly name", "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", "gb": "GB", + "general": { + "noOptionsFound": "No {{unit}} found" + }, "greeting": { "append": "The afternoon is your playground—let's make it epic!", "overview": "Here's an overview of your {{type}} monitors.", @@ -404,24 +443,24 @@ "incidentsOptionsHeader": "Incidents for:", "incidentsOptionsHeaderFilterAll": "All", "incidentsOptionsHeaderFilterBy": "Filter by:", - "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", - "incidentsTableResolvedAt": "Resolved at", "incidentsOptionsHeaderFilterDown": "Down", - "incidentsTableActionResolve": "Resolve", - "incidentsPageActionResolve": "Resolve all incidents", "incidentsOptionsHeaderFilterResolved": "Resolved", "incidentsOptionsHeaderLastDay": "Last day", "incidentsOptionsHeaderLastHour": "Last hour", "incidentsOptionsHeaderLastWeek": "Last week", "incidentsOptionsHeaderShow": "Show:", + "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "incidentsOptionsPlaceholderAllServers": "All servers", + "incidentsPageActionResolve": "Resolve all incidents", "incidentsPageTitle": "Incidents", + "incidentsTableActionResolve": "Resolve", "incidentsTableDateTime": "Date & Time", "incidentsTableMessage": "Message", "incidentsTableMonitorName": "Monitor Name", "incidentsTableNoIncidents": "No incidents recorded", "incidentsTablePaginationLabel": "incidents", + "incidentsTableResolvedAt": "Resolved at", "incidentsTableStatus": "Status", "incidentsTableStatusCode": "Status Code", "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", @@ -450,7 +489,6 @@ "invalidFileSize": "File size is too large!", "inviteNoTokenFound": "No invite token found", "loginHere": "Login here", - "logsPage": { "description": "System logs - last 1000 lines", "logLevelSelect": { @@ -464,36 +502,15 @@ } }, "tabs": { + "diagnostics": "Diagnostics", "logs": "Server logs", - "queue": "Job queue", - "diagnostics": "Diagnostics" + "queue": "Job queue" }, "title": "Logs", "toast": { "fetchLogsSuccess": "Logs fetched successfully" } }, - "diagnosticsPage": { - "diagnosticDescription": "System diagnostics", - "statsDescription": "System statistics", - "gauges": { - "heapAllocationTitle": "Heap allocation", - "heapAllocationSubtitle": "% of available memory", - "heapUsageTitle": "Heap usage", - "heapUsageSubtitle": "% of available memory", - "heapUtilizationTitle": "Heap utilization", - "heapUtilizationSubtitle": "% of allocated", - "instantCpuUsageTitle": "Instant CPU usage", - "instantCpuUsageSubtitle": "% of 1s used by CPU" - }, - "stats": { - "eventLoopDelayTitle": "Event loop delay", - "uptimeTitle": "Uptime", - "usedHeapSizeTitle": "Used heap size", - "totalHeapSizeTitle": "Total heap size", - "osMemoryLimitTitle": "OS Memory Limit" - } - }, "low": "low", "maintenance": "maintenance", "maintenanceRepeat": "Maintenance Repeat", @@ -506,24 +523,6 @@ "mem": "Mem", "memory": "Memory", "memoryUsage": "Memory usage", - "editUserPage": { - "title": "Edit user", - "form": { - "firstName": "First name", - "lastName": "Last name", - "email": "Email", - "role": "Roles", - "save": "Save" - }, - "table": { - "roleHeader": "Role", - "actionHeader": "Action" - }, - "toast": { - "validationErrors": "Validation errors", - "successUserUpdate": "User updated successfully" - } - }, "menu": { "changelog": "Changelog", "discussions": "Discussions", @@ -547,8 +546,8 @@ "message": "Message", "monitor": "monitor", "monitorActions": { - "deleteSuccess": "Monitor deleted successfully", "deleteFailed": "Failed to delete monitor", + "deleteSuccess": "Monitor deleted successfully", "details": "Details", "export": "Export Monitors", "import": "Import Monitors", @@ -846,6 +845,14 @@ "pending": "Pending...", "up": "Your site is up." }, + "statusPage": { + "contents": "Contents", + "createSuccess": "Status page created successfully", + "deleteFailed": "Failed to delete status page", + "deleteSuccess": "Status page deleted successfully", + "generalSettings": "General settings", + "updateSuccess": "Status page updated successfully" + }, "statusPageCreate": { "buttonSave": "Save" }, @@ -865,14 +872,6 @@ "statusPageStatusNoPage": "There's no status page here.", "statusPageStatusNotPublic": "This status page is not public.", "statusPageStatusServiceStatus": "Service status", - "statusPage": { - "deleteSuccess": "Status page deleted successfully", - "deleteFailed": "Failed to delete status page", - "createSuccess": "Status page created successfully", - "updateSuccess": "Status page updated successfully", - "generalSettings": "General settings", - "contents": "Contents" - }, "submit": "Submit", "teamPanel": { "cancel": "Cancel", @@ -900,13 +899,13 @@ "testLocale": "testLocale", "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", "time": { - "threeMinutes": "3 minutes", "fiveMinutes": "5 minutes", - "tenMinutes": "10 minutes", - "twentyMinutes": "20 minutes", - "oneHour": "1 hour", "oneDay": "1 day", - "oneWeek": "1 week" + "oneHour": "1 hour", + "oneWeek": "1 week", + "tenMinutes": "10 minutes", + "threeMinutes": "3 minutes", + "twentyMinutes": "20 minutes" }, "timeZoneInfo": "All dates and times are in GMT+0 time zone.", "timezone": "Timezone", From de88e1d442032807980457c27fc4d05f878804c0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 09:37:37 -0700 Subject: [PATCH 9/9] return early if delete superadmin --- client/src/Pages/Account/EditUser/hooks/editUser.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Pages/Account/EditUser/hooks/editUser.js b/client/src/Pages/Account/EditUser/hooks/editUser.js index 74914ee07..2ffa914a0 100644 --- a/client/src/Pages/Account/EditUser/hooks/editUser.js +++ b/client/src/Pages/Account/EditUser/hooks/editUser.js @@ -39,6 +39,7 @@ export const useEditUserForm = (user) => { }; const handleDeleteRole = (role) => { + if (role === ROLES.SUPERADMIN) return; setForm({ ...form, role: form?.role?.filter((r) => r !== role) }); };