diff --git a/client/src/Hooks/useEditUserForm.ts b/client/src/Hooks/useEditUserForm.ts new file mode 100644 index 000000000..53484ee07 --- /dev/null +++ b/client/src/Hooks/useEditUserForm.ts @@ -0,0 +1,13 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { editUserSchema, type EditUserFormData } from "@/Validation/editUser"; + +export const useEditUserForm = () => { + return { + resolver: zodResolver(editUserSchema), + defaults: { + firstName: "", + lastName: "", + role: [], + } as EditUserFormData, + }; +}; diff --git a/client/src/Pages/Account/EditUser/hooks/editUser.js b/client/src/Pages/Account/EditUser/hooks/editUser.js deleted file mode 100644 index aeb50ffff..000000000 --- a/client/src/Pages/Account/EditUser/hooks/editUser.js +++ /dev/null @@ -1,102 +0,0 @@ -import { useState, useEffect } from "react"; -import { ROLES } from "../../../../Utils/roleUtils.js"; -import { editUserValidation } from "../../../../Validation/validation.js"; -import Joi from "joi"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; -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 deleted file mode 100644 index 2b2ac5128..000000000 --- a/client/src/Pages/Account/EditUser/index.jsx +++ /dev/null @@ -1,136 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Search from "@/Components/v1/Inputs/Search/index.jsx"; -import Button from "@mui/material/Button"; -import RoleTable from "../components/RoleTable/index.jsx"; -import ChangePasswordModal from "@/Pages/Account/components/ChangePasswordModal/index.jsx"; -// Utils -import { useParams } from "react-router-dom"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { useGetUser, useEditUser } from "../../../Hooks/userHooks.js"; -import { EDITABLE_ROLES, ROLES } from "../../../Utils/roleUtils.js"; -import { useEditUserForm, useValidateEditUserForm } from "./hooks/editUser.js"; -import { useSelector } from "react-redux"; - -const EditUser = () => { - const { user } = useSelector((state) => state.auth); - const { userId } = useParams(); - const isSameUser = user?._id === userId; - const theme = useTheme(); - const { t } = useTranslation(); - const BREADCRUMBS = [ - { name: t("menu.team"), path: "/account/team" }, - { name: t("editUserPage.title"), path: "" }, - ]; - - const [userToEdit, isLoading, error] = useGetUser(userId); - const [editUser, isSaving, saveError, changePassword] = useEditUser(userId); - const [ - form, - setForm, - handleRoleChange, - handleDeleteRole, - searchInput, - handleSearchInput, - ] = useEditUserForm(userToEdit); - 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} - /> - - - - {!isSameUser && ( - - )} - - - - ); -}; - -export default EditUser; diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx new file mode 100644 index 000000000..483181c35 --- /dev/null +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -0,0 +1,183 @@ +import { Trash2 } from "lucide-react"; +import { TextField, Button, Autocomplete } from "@/Components/v2/inputs"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import Divider from "@mui/material/Divider"; +import FormHelperText from "@mui/material/FormHelperText"; +import { useForm, Controller } from "react-hook-form"; +import { ConfigBox, BasePage } from "@/Components/v2/design-elements"; + +import { UserRoles } from "@/Types/User"; +import { useTranslation } from "react-i18next"; +import type { UserRole, User } from "@/Types/User"; +import { useParams } from "react-router-dom"; +import { useTheme } from "@mui/material"; +import { useEditUserForm } from "@/Hooks/useEditUserForm"; +import { useGet, usePatch } from "@/Hooks/UseApi"; +import type { EditUserFormData } from "@/Validation/editUser"; +import { useEffect } from "react"; + +interface RoleOption { + id: UserRole; + name: string; +} + +const EditUserPage = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { userId } = useParams<{ userId: string }>(); + const { resolver, defaults } = useEditUserForm(); + const { patch, loading: isSaving } = usePatch(); + + const { data: user, isLoading } = useGet(`/auth/users/${userId}`); + + const { control, handleSubmit, reset, watch, setValue } = useForm({ + resolver, + defaultValues: defaults, + }); + + useEffect(() => { + if (user) { + reset({ + firstName: user.firstName || "", + lastName: user.lastName || "", + role: user.role || [], + }); + } + }, [user, reset]); + + const editableRoles = UserRoles.filter((role) => role !== "superadmin"); + const roleOptions: RoleOption[] = editableRoles.map((role) => ({ + id: role, + name: t(`common.auth.roles.${role}`), + })); + + const watchedRoles = watch("role"); + const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id)); + const handleRemoveRole = (roleToRemove: UserRole) => { + const newRoles = watchedRoles.filter((role) => role !== roleToRemove); + setValue("role", newRoles, { shouldValidate: true }); + }; + + const onSubmit = async (data: EditUserFormData) => { + await patch(`/auth/users/${userId}`, data); + }; + + return ( + +
+ + + ( + + )} + /> + ( + + )} + /> + + + } + /> + + ( + <> + option.name} + onChange={(_: unknown, newValue: RoleOption[]) => { + field.onChange(newValue.map((r) => r.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + /> + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + {selectedRoles.length > 0 && ( + + {selectedRoles.map((role, index) => ( + + {role.name} + handleRemoveRole(role.id)} + aria-label="Remove role" + > + + + {index < selectedRoles.length - 1 && } + + ))} + + )} + + } + /> + + + +
+ ); +}; + +export default EditUserPage; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 8ea8a50a0..dd1960e6f 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -90,8 +90,8 @@ export const TabProfile = () => { render={({ field, fieldState }) => ( { render={({ field, fieldState }) => ( ( ( ( field.onChange([e.target.value])} - fieldLabel={t("pages.account.team.invite.role.label")} - placeholder={t("pages.account.team.invite.role.placeholder")} + fieldLabel={t("common.form.role.option.role.label")} fullWidth > {roleOptions.map((option) => ( diff --git a/client/src/Pages/Account/components/ChangePasswordModal/index.jsx b/client/src/Pages/Account/components/ChangePasswordModal/index.jsx deleted file mode 100644 index 725a9b00e..000000000 --- a/client/src/Pages/Account/components/ChangePasswordModal/index.jsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useState } from "react"; -import { Button, Stack } from "@mui/material"; -import { GenericDialog } from "@/Components/v1/Dialog/genericDialog"; -import TextInput from "@/Components/v1/Inputs/TextInput"; -import PasswordTooltip from "@/Pages/Auth/components/PasswordTooltip"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { createToast } from "../../../../Utils/toastUtils"; -import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments"; -import usePasswordFeedback from "@/Pages/Auth/hooks/usePasswordFeedback"; -import PropTypes from "prop-types"; - -const ChangePasswordModal = ({ isSaving, isLoading, changePassword }) => { - const INITIAL_FORM_STATE = { - password: "", - confirm: "", - }; - const theme = useTheme(); - const { t } = useTranslation(); - const { feedback, handlePasswordFeedback } = usePasswordFeedback(); - const [form, setForm] = useState(INITIAL_FORM_STATE); - const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); - const [errors, setErrors] = useState({}); - const [isLoadingSubmit, setIsLoadingSubmit] = useState(false); - const closeChangePasswordModal = () => { - setIsChangePasswordOpen(false); - setForm(INITIAL_FORM_STATE); - }; - - const onChange = (e) => { - let { name, value } = e.target; - const updatedForm = { ...form, [name]: value }; - setForm(updatedForm); - - handlePasswordFeedback(updatedForm, name, value, form, errors, setErrors); - }; - const isFormValid = - form.password.length > 1 && - form.confirm.length > 1 && - !errors.password && - !errors.confirm; - const onsubmitChangePassword = async (event) => { - event.preventDefault(); - if (!isFormValid) return; - const newPasswordForm = { - password: form.password, - }; - try { - setIsLoadingSubmit(true); - await changePassword(newPasswordForm); - closeChangePasswordModal(); - } catch (error) { - const errorMsg = error.response?.data?.msg || error.message || "unknownError"; - createToast({ - type: "error", - body: t(errorMsg), - }); - } finally { - setIsLoadingSubmit(false); - } - }; - - return ( - <> - - - - } - sx={{ mb: theme.spacing(5) }} - /> - - } - sx={{ mb: theme.spacing(5) }} - /> - - - - - - - - ); -}; - -ChangePasswordModal.propTypes = { - isSaving: PropTypes.bool.isRequired, - isLoading: PropTypes.bool.isRequired, - changePassword: PropTypes.func.isRequired, -}; - -export default ChangePasswordModal; diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx index 24f1f58f9..02e5ba29c 100644 --- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -104,8 +104,8 @@ export const InviteTeamMemberDialog = ({ render={({ field, fieldState }) => ( onChange([e.target.value as UserRole])} - fieldLabel={t("pages.account.team.invite.role.label")} - placeholder={t("pages.account.team.invite.role.placeholder")} + fieldLabel={t("common.form.role.option.role.label")} fullWidth error={!!fieldState.error} > diff --git a/client/src/Pages/Account/components/RoleTable/index.jsx b/client/src/Pages/Account/components/RoleTable/index.jsx deleted file mode 100644 index 90a30dcd4..000000000 --- a/client/src/Pages/Account/components/RoleTable/index.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import Typography from "@mui/material/Typography"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Icon from "@/Components/v1/Icon"; - -import { useTranslation } from "react-i18next"; -import { ROLES } from "../../../../Utils/roleUtils.js"; - -const RoleTable = ({ roles, handleDeleteRole }) => { - 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); - }} - style={{ cursor: "pointer" }} - /> - ); - }, - }, - ]; - return ( - - ); -}; - -export default RoleTable; diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 80cee579f..75d27649f 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -86,8 +86,8 @@ const RegisterPage = () => { render={({ field, fieldState }) => ( @@ -99,8 +99,8 @@ const RegisterPage = () => { render={({ field, fieldState }) => ( @@ -113,8 +113,8 @@ const RegisterPage = () => { diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 282a5db56..4c68c0367 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -46,7 +46,7 @@ import CreateNotifications from "../Pages/Notifications/create"; // Settings import Account from "@/Pages/Account"; -import EditUser from "../Pages/Account/EditUser/index.jsx"; +import EditUser from "../Pages/Account/EditUser"; import Settings from "../Pages/Settings"; import Maintenance from "../Pages/Maintenance"; @@ -354,7 +354,9 @@ const Routes = () => { path="account/team/:userId" element={ - + + + } /> diff --git a/client/src/Validation/editUser.ts b/client/src/Validation/editUser.ts new file mode 100644 index 000000000..6fb42a6be --- /dev/null +++ b/client/src/Validation/editUser.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { namePattern } from "@/Validation/patterns"; +import { UserRoles } from "@/Types/User"; + +export const editUserSchema = z.object({ + firstName: z + .string() + .min(1, "First name is required") + .max(50, "First name must be 50 characters or less") + .regex(namePattern, "First name contains invalid characters"), + lastName: z + .string() + .min(1, "Last name is required") + .max(50, "Last name must be 50 characters or less") + .regex(namePattern, "Last name contains invalid characters"), + role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), +}); + +export type EditUserFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 69684af6a..4b3885cf2 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -237,6 +237,35 @@ "resolved": "Resolved", "responseTime": "Response time" }, + "form": { + "role": { + "option": { + "role": { + "label": "Roles" + } + } + }, + "name": { + "option": { + "firstName": { + "label": "First name", + "placeholder": "Enter your first name" + }, + "lastName": { + "label": "Last name", + "placeholder": "Enter your last name" + } + } + }, + "email": { + "option": { + "email": { + "label": "Email", + "placeholder": "you@example.com" + } + } + } + }, "table": { "empty": "Nothing here", "headers": { @@ -522,17 +551,7 @@ "form": { "name": { "title": "Name", - "description": "Update your personal information", - "option": { - "firstName": { - "label": "First name", - "placeholder": "Enter your first name" - }, - "lastName": { - "label": "Last name", - "placeholder": "Enter your last name" - } - } + "description": "Update your personal information" }, "photo": { "title": "Profile photo", @@ -634,19 +653,7 @@ "register": { "title": "Welcome back to Checkmate!", "subtitle": "Sign up to get started", - "submit": "Register", - "form": { - "option": { - "name": { - "label": "Name", - "placeholder": "Your name" - }, - "surname": { - "label": "Surname", - "placeholder": "Your surname" - } - } - } + "submit": "Register" }, "forgotPassword": { "title": "Forgot your password?", @@ -812,6 +819,14 @@ } } }, + "editUser": { + "form": { + "roles": { + "title": "Roles", + "description": "Assign roles to the user. Multiple roles can be selected." + } + } + }, "incidents": { "dialog": { "details": { diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index f01a8da40..63be09932 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -689,7 +689,6 @@ const editUserByIdParamValidation = joi.object({ const editUserByIdBodyValidation = joi.object({ firstName: nameValidation.required(), lastName: nameValidation.required(), - email: joi.string().email().required(), role: joi .array() .items(joi.string().valid(...UserRoles)) @@ -700,7 +699,6 @@ const editUserByIdBodyValidation = joi.object({ const editSuperadminUserByIdBodyValidation = joi.object({ firstName: nameValidation.required(), lastName: nameValidation.required(), - email: joi.string().email().required(), role: joi .array() .items(joi.string().valid(...UserRoles))