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 (
+
+
+
+ );
+};
+
+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))