diff --git a/client/src/Components/v2/inputs/Dialog.tsx b/client/src/Components/v2/inputs/Dialog.tsx
index 717126edb..b581e7390 100644
--- a/client/src/Components/v2/inputs/Dialog.tsx
+++ b/client/src/Components/v2/inputs/Dialog.tsx
@@ -23,6 +23,7 @@ export const DialogInput = ({
children,
maxWidth,
fullWidth = false,
+ additionalButtons,
}: {
open: boolean;
title?: string;
@@ -37,6 +38,7 @@ export const DialogInput = ({
children?: ReactNode;
maxWidth?: DialogProps["maxWidth"];
fullWidth?: boolean;
+ additionalButtons?: ReactNode;
}) => {
const { t } = useTranslation();
return (
@@ -59,6 +61,7 @@ export const DialogInput = ({
>
{cancelText ?? t("common.buttons.cancel")}
+ {additionalButtons}
{onConfirm && (
-
- >
- );
-};
-
-AddMemberMenu.propTypes = {
- handleInviteOpen: Proptypes.func.isRequired,
- handleIsRegisterOpen: Proptypes.func.isRequired,
-};
-
-export default AddMemberMenu;
diff --git a/client/src/Pages/Account/components/AddTeamMember/hooks/useAddTeamMember.jsx b/client/src/Pages/Account/components/AddTeamMember/hooks/useAddTeamMember.jsx
deleted file mode 100644
index 1237777ff..000000000
--- a/client/src/Pages/Account/components/AddTeamMember/hooks/useAddTeamMember.jsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { useState } from "react";
-import { newOrChangedCredentials } from "../../../../../Validation/validation.js";
-import { useTranslation } from "react-i18next";
-const useAddTeamMember = () => {
- const { t } = useTranslation();
- const [errors, setErrors] = useState({});
-
- const clearErrors = () => setErrors({});
-
- const validateFields = (name, value, formData) => {
- const { error } = newOrChangedCredentials.validate(
- { [name]: value },
- { abortEarly: false, context: { password: formData.password } }
- );
-
- setErrors((prev) => ({
- ...prev,
- [name]: error?.details?.[0]?.message || "",
- }));
- };
-
- const validateForm = (formData, role) => {
- const { error } = newOrChangedCredentials.validate(formData, {
- abortEarly: false,
- context: { password: formData.password },
- });
- const formErrors = {};
- if (error) {
- for (const err of error.details) {
- formErrors[err.path[0]] = err.message;
- }
- }
- if (!role[0] || role.length === 0) {
- formErrors.role = t(
- "teamPanel.registerTeamMember.auth.common.inputs.role.errors.empty"
- );
- }
- if (Object.keys(formErrors).length > 0) {
- setErrors(formErrors);
- return false;
- }
- setErrors({});
- return true;
- };
-
- return { errors, setErrors, clearErrors, validateFields, validateForm };
-};
-export default useAddTeamMember;
diff --git a/client/src/Pages/Account/components/AddTeamMember/index.jsx b/client/src/Pages/Account/components/AddTeamMember/index.jsx
deleted file mode 100644
index e5280af16..000000000
--- a/client/src/Pages/Account/components/AddTeamMember/index.jsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { Button, Stack } from "@mui/material";
-import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
-import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
-import Select from "@/Components/v1/Inputs/Select/index.jsx";
-import { useGetInviteToken } from "../../../../Hooks/inviteHooks.js";
-import { useTheme } from "@emotion/react";
-import { useTranslation } from "react-i18next";
-import { createToast } from "../../../../Utils/toastUtils.jsx";
-import { useState } from "react";
-import PasswordTooltip from "../../../Auth/components/PasswordTooltip.jsx";
-import useAddTeamMember from "./hooks/useAddTeamMember.jsx";
-import usePasswordFeedback from "../../../Auth/hooks/usePasswordFeedback.jsx";
-import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx";
-import PropTypes from "prop-types";
-
-const INITIAL_FORM_STATE = {
- firstName: "",
- lastName: "",
- email: "",
- password: "",
- confirm: "",
- teamId: "",
-};
-
-const INITIAL_ROLE_STATE = ["user"];
-const AddTeamMember = ({ handleIsRegisterOpen, isRegisterOpen, onMemberAdded }) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const { errors, setErrors, clearErrors, validateFields, validateForm } =
- useAddTeamMember();
- const { feedback, handlePasswordFeedback } = usePasswordFeedback();
- const [getInviteToken, clearToken, isLoading, error, token, addTeamMember] =
- useGetInviteToken();
- const [form, setForm] = useState(INITIAL_FORM_STATE);
- const [role, setRole] = useState(INITIAL_ROLE_STATE);
- const [isLoadingSubmit, setIsLoadingSubmit] = useState(false);
- const closeAddMemberModal = () => {
- handleIsRegisterOpen(false);
- setForm(INITIAL_FORM_STATE);
- setRole(INITIAL_ROLE_STATE);
- clearErrors();
- clearToken();
- };
-
- const onChange = (e) => {
- let { name, value } = e.target;
- if (name === "email") value = value.toLowerCase();
- const updatedForm = { ...form, [name]: value };
- validateFields(name, value, updatedForm);
- setForm(updatedForm);
-
- if (name === "password" || name === "confirm") {
- handlePasswordFeedback(updatedForm, name, value, form, errors, setErrors);
- }
- };
-
- const onsubmitAddMember = async (event) => {
- event.preventDefault();
- if (!validateForm(form, role)) return;
- try {
- setIsLoadingSubmit(true);
- await addTeamMember(form, role);
- createToast({
- body: t("teamPanel.registerToast.success"),
- });
- onMemberAdded();
- closeAddMemberModal();
- } catch (error) {
- const errorMsg = error.response?.data?.msg || error.message || "unknownError";
- createToast({
- type: "error",
- body: t(errorMsg),
- });
- } finally {
- setIsLoadingSubmit(false);
- }
- };
- const tErr = (key) => (key ? t([`teamPanel.registerTeamMember.${key}`, key]) : "");
- return (
- <>
-
-
-
-
-
-
-
-
- >
- );
-};
-AddTeamMember.propTypes = {
- handleIsRegisterOpen: PropTypes.func.isRequired,
- isRegisterOpen: PropTypes.bool.isRequired,
- onMemberAdded: PropTypes.func.isRequired,
-};
-
-export default AddTeamMember;
diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx
new file mode 100644
index 000000000..170af1724
--- /dev/null
+++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx
@@ -0,0 +1,187 @@
+import { Stack } from "@mui/material";
+import MenuItem from "@mui/material/MenuItem";
+import { useTheme } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import { useForm, Controller } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
+import { Dialog, TextField, Select } from "@/Components/v2/inputs";
+import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
+import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
+import type { UserRole, User } from "@/Types/User";
+import { usePost } from "@/Hooks/UseApi";
+
+interface AddTeamMemberDialogProps {
+ open: boolean;
+ onClose: () => void;
+ onSuccess?: () => void;
+}
+
+interface RegisterPayload {
+ firstName: string;
+ lastName: string;
+ email: string;
+ password: string;
+ role: string[];
+}
+
+export const AddTeamMemberDialog = ({
+ open,
+ onClose,
+ onSuccess,
+}: AddTeamMemberDialogProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { schema, defaults } = useAddTeamMemberForm();
+ const { post, loading } = usePost();
+
+ const { control, handleSubmit, reset, setError } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: defaults,
+ values: defaults,
+ });
+
+ const roleOptions: { value: UserRole; label: string }[] = [
+ { value: "admin", label: t("common.auth.roles.admin") },
+ { value: "user", label: t("common.auth.roles.user") },
+ ];
+
+ const handleClose = () => {
+ reset();
+ onClose();
+ };
+
+ const onSubmit = async (data: AddTeamMemberFormData) => {
+ if (loading) return;
+
+ const { confirm, ...userData } = data;
+ const payload: RegisterPayload = userData;
+
+ const result = await post("/auth/users", payload);
+
+ if (result?.success) {
+ reset();
+ onClose();
+ onSuccess?.();
+ } else if (result?.msg) {
+ if (result.msg.toLowerCase().includes("email")) {
+ setError("email", { message: result.msg });
+ }
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx
new file mode 100644
index 000000000..57f000170
--- /dev/null
+++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx
@@ -0,0 +1,80 @@
+import Stack from "@mui/material/Stack";
+import MenuItem from "@mui/material/MenuItem";
+import { Button, Select } from "@/Components/v2/inputs";
+import { Icon } from "@/Components/v2/design-elements";
+import { UserPlus, Mail } from "lucide-react";
+
+import { UserRoles } from "@/Types/User";
+import { useTheme } from "@mui/material/styles";
+import type { UserRole } from "@/Types/User";
+import { useTranslation } from "react-i18next";
+import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
+
+interface HeaderTeamControlsProps {
+ filter: UserRole | "";
+ onFilterChange: (value: UserRole | "") => void;
+ onInviteClick?: () => void;
+ onAddMemberClick?: () => void;
+}
+
+export const HeaderTeamControls = ({
+ filter,
+ onFilterChange,
+ onInviteClick,
+ onAddMemberClick,
+}: HeaderTeamControlsProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const isAdmin = useIsAdmin();
+ const isSuperAdmin = useIsSuperAdmin();
+
+ const handleFilterChange = (event: { target: { value: unknown } }) => {
+ onFilterChange(event.target.value as UserRole | "");
+ };
+
+ return (
+
+
+ {isAdmin && (
+ }
+ onClick={onInviteClick}
+ >
+ {t("common.buttons.inviteMember")}
+
+ )}
+ {isSuperAdmin && (
+ }
+ onClick={onAddMemberClick}
+ >
+ {t("common.buttons.addMember")}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx
new file mode 100644
index 000000000..24f1f58f9
--- /dev/null
+++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx
@@ -0,0 +1,164 @@
+import { Stack } from "@mui/material";
+import MenuItem from "@mui/material/MenuItem";
+import { useTheme, FormHelperText, Typography } from "@mui/material";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useForm, Controller } from "react-hook-form";
+import { Dialog, TextField, Select, Button } from "@/Components/v2/inputs";
+import type { UserRole } from "@/Types/User";
+import { useInviteForm } from "@/Hooks/useInviteForm";
+import type { InviteFormData } from "@/Validation/invite";
+import { usePost } from "@/Hooks/UseApi";
+
+const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
+
+interface InviteResponse {
+ token: string;
+}
+
+interface InviteTeamMemberDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const InviteTeamMemberDialog = ({
+ open,
+ onClose,
+}: InviteTeamMemberDialogProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { resolver, defaults } = useInviteForm();
+
+ const { control, handleSubmit, reset } = useForm({
+ resolver,
+ defaultValues: defaults,
+ values: defaults,
+ });
+
+ const { post: generateToken, loading: generateLoading } = usePost<
+ InviteFormData,
+ InviteResponse
+ >();
+ const { post: sendInvite, loading: sendLoading } = usePost<
+ InviteFormData,
+ InviteResponse
+ >();
+ const [inviteLink, setInviteLink] = useState(null);
+
+ const roleOptions: { value: UserRole; label: string }[] = [
+ { value: "admin", label: t("common.auth.roles.admin") },
+ { value: "user", label: t("common.auth.roles.user") },
+ ];
+
+ const handleGenerateToken = async (data: InviteFormData) => {
+ const result = await generateToken("/invite", data);
+ if (result?.data?.token) {
+ const token = result.data.token;
+ const link = CLIENT_HOST ? `${CLIENT_HOST}/register/${token}` : token;
+ setInviteLink(link);
+ }
+ };
+
+ const handleSendInvite = async (data: InviteFormData) => {
+ const result = await sendInvite("/invite/send", data);
+ if (result?.success) {
+ handleClose();
+ }
+ };
+
+ const handleClose = () => {
+ reset();
+ setInviteLink(null);
+ onClose();
+ };
+
+ return (
+
+ );
+};
diff --git a/client/src/Pages/Account/components/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx
deleted file mode 100644
index b2a7d8ee0..000000000
--- a/client/src/Pages/Account/components/PasswordPanel.jsx
+++ /dev/null
@@ -1,302 +0,0 @@
-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 "@/Components/v1/Inputs/TextInput/Adornments/index.jsx";
-import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
-import { newOrChangedCredentials } from "../../../Validation/validation.js";
-import Alert from "@/Components/v1/Alert/index.jsx";
-import { update } from "../../../Features/Auth/authSlice.js";
-import { useDispatch, useSelector } from "react-redux";
-import { createToast } from "../../../Utils/toastUtils.jsx";
-import { getTouchedFieldErrors } from "../../../Validation/error.js";
-import { useTranslation } from "react-i18next";
-import { useNavigate, useLocation } from "react-router-dom";
-import { clearAuthState } from "../../../Features/Auth/authSlice.js";
-import TextLink from "@/Components/v1/TextLink/index.jsx";
-
-const defaultPasswordsState = {
- password: "",
- newPassword: "",
- confirm: "",
-};
-
-/**
- * PasswordPanel component manages the form for editing password.
- *
- * @returns {JSX.Element}
- */
-
-const PasswordPanel = () => {
- const theme = useTheme();
- const dispatch = useDispatch();
- const { t } = useTranslation();
- const navigate = useNavigate();
- const location = useLocation();
-
- const SPACING_GAP = theme.spacing(12);
-
- //redux state
- const { isLoading, authToken } = useSelector((state) => state.auth);
-
- const idToName = {
- "edit-current-password": "password",
- "edit-new-password": "newPassword",
- "edit-confirm-password": "confirm",
- };
-
- const [localData, setLocalData] = useState(defaultPasswordsState);
- const [errors, setErrors] = useState(defaultPasswordsState);
- const [touchedFields, setTouchedFields] = useState({
- password: false,
- newPassword: false,
- confirm: false,
- });
-
- const handleChange = (event) => {
- const { value, id } = event.target;
- const name = idToName[id];
-
- const updatedData = {
- ...localData,
- [name]: value,
- };
- const updatedTouchedFields = {
- ...touchedFields,
- [name]: true,
- };
-
- const validation = newOrChangedCredentials.validate(
- { ...updatedData },
- { abortEarly: false, context: { password: updatedData.newPassword } }
- );
-
- const updatedErrors = getTouchedFieldErrors(validation, updatedTouchedFields);
-
- if (!touchedFields[name]) {
- setTouchedFields(updatedTouchedFields);
- }
-
- setLocalData(updatedData);
- setErrors(updatedErrors);
- };
-
- const handleSubmit = async (event) => {
- event.preventDefault();
-
- // Check for expired/invalid token before submitting
- if (!authToken) {
- createToast({
- body: "Your session has expired. Please log in again.",
- });
- dispatch(clearAuthState());
- navigate("/login");
- return;
- }
-
- const { error } = newOrChangedCredentials.validate(localData, {
- abortEarly: false,
- context: { password: localData.newPassword },
- });
-
- if (error) {
- const newErrors = {};
- error.details.forEach((err) => {
- newErrors[err.path[0]] = err.message;
- });
- setErrors(newErrors);
- } else {
- const action = await dispatch(update({ localData }));
- if (action.payload.success) {
- createToast({
- body: t(
- "passwordPanel.passwordChangedSuccess",
- "Your password was changed successfully."
- ),
- });
- setLocalData({
- password: "",
- newPassword: "",
- confirm: "",
- });
- } else {
- // TODO: Check for other errors?
- createToast({
- body: t(
- "passwordPanel.passwordInputIncorrect",
- "Your password input was incorrect."
- ),
- });
- setErrors({ password: "*" + action.payload.msg + "." });
- }
- }
- };
-
- return (
-
-
-
-
-
-
- {t("passwordPanel.currentPassword", "Current password")}
-
- }
- flex={1}
- />
-
-
-
- {t("passwordPanel.newPassword", "New password")}
-
-
- }
- flex={1}
- />
-
-
-
- {t("passwordPanel.confirmNewPassword", "Confirm new password")}
-
-
- }
- flex={1}
- />
-
- {Object.keys(errors).length > 0 && (
-
-
-
- )}
-
-
- 0 ||
- Object.values(localData).filter((value) => value === "").length > 0
- }
- sx={{
- px: theme.spacing(12),
- }}
- >
- {t("commonSave", "Save")}
-
-
-
-
- );
-};
-
-PasswordPanel.propTypes = {
- // No props are being passed to this component, hence no specific PropTypes are defined.
-};
-
-export default PasswordPanel;
diff --git a/client/src/Pages/Account/components/ProfilePanel.jsx b/client/src/Pages/Account/components/ProfilePanel.jsx
deleted file mode 100644
index dfe508879..000000000
--- a/client/src/Pages/Account/components/ProfilePanel.jsx
+++ /dev/null
@@ -1,437 +0,0 @@
-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 "@/Components/v1/Avatar/index.jsx";
-import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
-import ImageUpload from "@/Components/v1/Inputs/ImageUpload/index.jsx";
-import { newOrChangedCredentials } from "../../../Validation/validation.js";
-import { useDispatch, useSelector } from "react-redux";
-import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice.js";
-import { createToast } from "../../../Utils/toastUtils.jsx";
-import { logger } from "../../../Utils/Logger.js";
-import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
-import Dialog from "@/Components/v1/Dialog/index.jsx";
-import { useTranslation } from "react-i18next";
-
-/**
- * ProfilePanel component displays a form for editing user profile information
- * and allows for actions like updating profile picture, credentials,
- * and deleting account.
- *
- * @returns {JSX.Element}
- */
-
-const ProfilePanel = () => {
- const theme = useTheme();
- const dispatch = useDispatch();
- const { t } = useTranslation();
- const SPACING_GAP = theme.spacing(12);
-
- //redux state
- const { user, isLoading } = useSelector((state) => state.auth);
-
- const idToName = {
- "edit-first-name": "firstName",
- "edit-last-name": "lastName",
- // Disabled for now, will revisit in the future
- // "edit-email": "email",
- };
-
- // Local state for form data, errors, and file handling
- const [localData, setLocalData] = useState({
- firstName: user.firstName,
- lastName: user.lastName,
- // email: user.email, // Disabled for now
- });
- const [errors, setErrors] = useState({});
- const [file, setFile] = useState();
-
- // Handles input field changes and performs validation
- const handleChange = (event) => {
- errors["unchanged"] && clearError("unchanged");
- const { value, id } = event.target;
- const name = idToName[id];
- setLocalData((prev) => ({
- ...prev,
- [name]: value,
- }));
-
- validateField({ [name]: value }, newOrChangedCredentials, name);
- };
-
- // Validates input against provided schema and updates error state
- const validateField = (toValidate, schema, name = "picture") => {
- const { error } = schema.validate(toValidate, { abortEarly: false });
- setErrors((prev) => {
- const prevErrors = { ...prev };
- if (error) prevErrors[name] = error.details[0].message;
- else delete prevErrors[name];
- return prevErrors;
- });
- if (error) return true;
- };
-
- // Clears specific error from errors state
- const clearError = (err) => {
- setErrors((prev) => {
- const updatedErrors = { ...prev };
- if (updatedErrors[err]) delete updatedErrors[err];
- return updatedErrors;
- });
- };
-
- // Resets picture-related states and clears interval
- const removePicture = () => {
- errors["picture"] && clearError("picture");
- setFile(undefined);
- setLocalData((prev) => ({
- ...prev,
- file: undefined,
- deleteProfileImage: true,
- }));
- };
-
- // Opens the picture update modal
- const openPictureModal = () => {
- setIsOpen("picture");
- setFile(undefined);
- };
-
- // Closes the picture update modal and resets related states
- const closePictureModal = () => {
- if (errors["picture"]) clearError("picture");
- setFile(undefined);
- setIsOpen("");
- };
-
- // Updates profile image displayed on UI
- const handleUpdatePicture = () => {
- setLocalData((prev) => ({
- ...prev,
- file: file?.src,
- deleteProfileImage: false,
- }));
- setIsOpen("");
- if (errors["unchanged"]) clearError("unchanged");
- };
-
- // Handles form submission to update user profile
- const handleSaveProfile = async (event) => {
- event.preventDefault();
- const nameChanged =
- localData.firstName !== user.firstName || localData.lastName !== user.lastName;
-
- const avatarChanged =
- localData.deleteProfileImage === true ||
- (localData.file && localData.file !== `data:image/png;base64,${user.avatarImage}`);
-
- if (!nameChanged && !avatarChanged) {
- createToast({
- body: "Unable to update profile — no changes detected.",
- });
- setErrors({ unchanged: "unable to update profile" });
- return;
- }
-
- const action = await dispatch(update({ localData }));
- if (action.payload.success) {
- createToast({
- body: "Your profile data was changed successfully.",
- });
- } else {
- createToast({
- body: "There was an error updating your profile data.",
- });
- }
- };
-
- // Removes current profile image from UI
- const handleDeletePicture = () => {
- setLocalData((prev) => ({
- ...prev,
- deleteProfileImage: true,
- }));
- setFile(undefined);
- errors["unchanged"] && clearError("unchanged");
- };
-
- // Initiates the account deletion process
- const handleDeleteAccount = async () => {
- const action = await dispatch(deleteUser());
- if (action.payload.success) {
- dispatch(clearAuthState());
- } else {
- if (action.payload) {
- // dispatch errors
- createToast({
- body: action.payload.msg,
- });
- } else {
- // unknown errors
- createToast({
- body: "Unknown error.",
- });
- }
- }
- };
-
- // Modal state and control functions
- const [isOpen, setIsOpen] = useState("");
- const isModalOpen = (name) => isOpen === name;
-
- return (
-
-
-
- {/* This 0.9 is a bit magic numbering, refactor */}
-
- {t("FirstName")}
-
-
-
-
-
- {t("LastName")}
-
-
-
-
-
- {t("auth.common.inputs.email.label")}
-
- {t("EmailDescriptionText")}
-
-
- logger.warn("disabled")}
- disabled={true}
- flex={1}
- />
-
-
-
- {t("YourPhoto")}
-
- {t("PhotoDescriptionText")}
-
-
-
-
-
- {t("delete")}
-
-
- {t("update")}
-
-
-
-
-
-
-
- {t("save")}
-
-
-
-
-
- {!user.role.includes("demo") && (
-
-
- {t("DeleteAccountTitle")}
-
- {t("DeleteDescriptionText")}
-
-
- setIsOpen("delete")}
- >
- {t("DeleteAccountButton")}
-
-
- )}
-
- );
-};
-
-ProfilePanel.propTypes = {
- // No props are being passed to this component, hence no specific PropTypes are defined.
-};
-
-export default ProfilePanel;
diff --git a/client/src/Pages/Account/components/TeamPanel.jsx b/client/src/Pages/Account/components/TeamPanel.jsx
deleted file mode 100644
index 6c658cc60..000000000
--- a/client/src/Pages/Account/components/TeamPanel.jsx
+++ /dev/null
@@ -1,360 +0,0 @@
-import { useTheme } from "@emotion/react";
-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 "@/Components/v1/Inputs/TextInput/index.jsx";
-import { newOrChangedCredentials } from "../../../Validation/validation.js";
-import { networkService } from "../../../main.jsx";
-import { createToast } from "../../../Utils/toastUtils.jsx";
-import Select from "@/Components/v1/Inputs/Select/index.jsx";
-import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
-import AddTeamMember from "./AddTeamMember/index.jsx";
-import DataTable from "@/Components/v1/Table/index.jsx";
-import { useGetInviteToken } from "../../../Hooks/inviteHooks.js";
-import { useNavigate } from "react-router-dom";
-import { useIsSuperAdmin } from "@/Hooks/useIsAdmin.js";
-import AddMemberMenu from "./AddMemberMenu/index.jsx";
-/**
- * TeamPanel component manages the organization and team members,
- * providing functionalities like renaming the organization, managing team members,
- * and inviting new members.
- *
- * @returns {JSX.Element}
- */
-
-const TeamPanel = () => {
- const theme = useTheme();
- const { t } = useTranslation();
- const SPACING_GAP = theme.spacing(12);
-
- const [toInvite, setToInvite] = useState({
- email: "",
- role: ["user"],
- });
- const [data, setData] = useState([]);
- const [members, setMembers] = useState([]);
- const [filter, setFilter] = useState("all");
- 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 = [
- {
- id: "name",
- content: t("teamPanel.table.name"),
- render: (row) => {
- return (
-
-
- {row.firstName + " " + row.lastName}
-
-
- {t("teamPanel.table.created")}{" "}
- {new Date(row.createdAt).toLocaleDateString()}
-
-
- );
- },
- },
- { id: "email", content: t("teamPanel.table.email"), render: (row) => row.email },
- {
- id: "role",
- content: t("teamPanel.table.role"),
- render: (row) => row.role,
- },
- ];
- const [refreshTrigger, setRefreshTrigger] = useState(false);
- const refreshTeamList = () => {
- setRefreshTrigger((prev) => !prev);
- };
- useEffect(() => {
- const fetchTeam = async () => {
- try {
- const response = await networkService.getAllUsers();
- setMembers(response.data.data);
- } catch (error) {
- createToast({
- body: error.message || "Error fetching team members.",
- });
- }
- };
-
- fetchTeam();
- }, [refreshTrigger]);
-
- useEffect(() => {
- const ROLE_MAP = {
- superadmin: t("roles.superAdmin"),
- admin: t("roles.admin"),
- user: t("roles.teamMember"),
- demo: t("roles.demoUser"),
- };
- let team = members;
- if (filter !== "all")
- team = members.filter((member) => {
- if (filter === "admin") {
- return member.role.includes("admin") || member.role.includes("superadmin");
- }
- return member.role.includes(filter);
- });
-
- team = team.map((member) => ({
- ...member,
- id: member.id,
- role: member.role.map((role) => ROLE_MAP[role]).join(","),
- }));
- setData(team);
- }, [members, filter, t]);
-
- useEffect(() => {
- setIsDisabled(Object.keys(errors).length !== 0 || toInvite.email === "");
- }, [errors, toInvite.email]);
- const [isOpen, setIsOpen] = useState(false);
- const [isRegisterOpen, setIsRegisterOpen] = useState(false);
- const handleIsRegisterOpen = (open) => {
- setIsRegisterOpen(open);
- };
-
- const handleChange = (event) => {
- const { value } = event.target;
- const newEmail = value?.toLowerCase() || value;
- setToInvite((prev) => ({
- ...prev,
- email: newEmail,
- }));
-
- const validation = newOrChangedCredentials.validate(
- { email: newEmail },
- { abortEarly: false }
- );
-
- setErrors((prev) => {
- const updatedErrors = { ...prev };
-
- if (validation.error) {
- updatedErrors.email = validation.error.details[0].message;
- } else {
- delete updatedErrors.email;
- }
- return updatedErrors;
- });
- };
-
- const handleGetToken = async () => {
- await getInviteToken({ email: toInvite.email, role: toInvite.role });
- };
- const handleInviteMember = async () => {
- if (!toInvite.email) {
- setErrors((prev) => ({ ...prev, email: "Email is required." }));
- return;
- }
- setIsSendingInvite(true);
- if (!toInvite.role.includes("user") || !toInvite.role.includes("admin"))
- setToInvite((prev) => ({ ...prev, role: ["user"] }));
-
- const { error } = newOrChangedCredentials.validate(
- { email: toInvite.email },
- {
- abortEarly: false,
- }
- );
-
- if (error) {
- setErrors((prev) => ({ ...prev, email: error.details[0].message }));
- return;
- }
-
- try {
- await networkService.sendInvitationToken({
- email: toInvite.email,
- role: toInvite.role,
- });
- closeInviteModal();
- createToast({
- body: "Member invited. They will receive an email with details on how to create their account.",
- });
- } catch (error) {
- createToast({
- body: error?.response?.data?.msg || error.message || "Unknown error.",
- });
- } finally {
- setIsSendingInvite(false);
- }
- };
-
- const closeInviteModal = () => {
- setIsOpen(false);
- clearToken();
- setToInvite({ email: "", role: ["0"] });
- setErrors({});
- };
-
- return (
-
-
- {t("teamPanel.teamMembers")}
-
-
-
- setFilter("all")}
- >
- {t("teamPanel.filter.all")}
-
- setFilter("admin")}
- >
- {t("roles.superAdmin")}
-
- setFilter("user")}
- >
- {t("teamPanel.filter.member")}
-
-
-
-
-
- setIsOpen(true)}
- handleIsRegisterOpen={handleIsRegisterOpen}
- />
-
-
- {
- if (isSuperAdmin) {
- navigate(`/account/team/${row.id}`);
- }
- },
- }}
- />
-
-
-
-
-
- );
-};
-
-TeamPanel.propTypes = {
- // No props are being passed to this component, hence no specific PropTypes are defined.
-};
-
-export default TeamPanel;
diff --git a/client/src/Pages/Account/components/TeamTable.tsx b/client/src/Pages/Account/components/TeamTable.tsx
new file mode 100644
index 000000000..2583d1c39
--- /dev/null
+++ b/client/src/Pages/Account/components/TeamTable.tsx
@@ -0,0 +1,60 @@
+import Typography from "@mui/material/Typography";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { Table } from "@/Components/v2/design-elements";
+import type { Header } from "@/Components/v2/design-elements/Table";
+import { useIsSuperAdmin } from "@/Hooks/useIsAdmin";
+import type { User } from "@/Types/User";
+
+interface TeamTableProps {
+ users: User[];
+}
+
+export const TeamTable = ({ users }: TeamTableProps) => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const isSuperAdmin = useIsSuperAdmin();
+
+ const headers: Header[] = [
+ {
+ id: "name",
+ content: t("common.table.headers.name"),
+ render: (row) => {`${row.firstName} ${row.lastName}`},
+ },
+ {
+ id: "email",
+ content: t("pages.account.team.table.headers.email"),
+ render: (row) => {row.email},
+ },
+ {
+ id: "role",
+ content: t("pages.account.team.table.headers.role"),
+ render: (row) => (
+
+ {row.role.map((r) => t(`common.auth.roles.${r}`)).join(", ")}
+
+ ),
+ },
+ {
+ id: "created",
+ content: t("pages.account.team.table.headers.created"),
+ render: (row) => (
+ {new Date(row.createdAt).toLocaleDateString()}
+ ),
+ },
+ ];
+
+ const handleRowClick = (row: User) => {
+ if (isSuperAdmin) {
+ navigate(`/account/team/${row.id}`);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/client/src/Pages/Account/index.jsx b/client/src/Pages/Account/index.jsx
deleted file mode 100644
index 3bf480336..000000000
--- a/client/src/Pages/Account/index.jsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useState } from "react";
-import PropTypes from "prop-types";
-import { useNavigate } from "react-router-dom";
-import { useSelector } from "react-redux";
-import { Box, Tab, useTheme } from "@mui/material";
-import CustomTabList from "@/Components/v1/Tab/index.jsx";
-import TabContext from "@mui/lab/TabContext";
-import ProfilePanel from "./components/ProfilePanel.jsx";
-import PasswordPanel from "./components/PasswordPanel.jsx";
-import TeamPanel from "./components/TeamPanel.jsx";
-import { useTranslation } from "react-i18next";
-
-/**
- * Account component renders a settings page with tabs for Profile, Password, and Team settings.
- * @param {string} [props.open] - Specifies the initially open tab: 'profile', 'password', or 'team'.
- * @returns {JSX.Element}
- */
-const Account = ({ open = "profile" }) => {
- const theme = useTheme();
- const navigate = useNavigate();
- const [focusedTab, setFocusedTab] = useState(null); // Track focused tab
- const tab = open;
- const handleTabChange = (event, newTab) => {
- navigate(`/account/${newTab}`);
- };
- const { user } = useSelector((state) => state.auth);
- const { t } = useTranslation();
-
- const requiredRoles = ["superadmin", "admin"];
- let tabList = [
- { name: t("menu.profile"), value: "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" },
- { name: t("menu.password"), value: "password" },
- ];
- }
-
- // Remove password for demo
- if (user.role.includes("demo")) {
- tabList = [{ name: t("menu.profile"), value: "profile" }];
- }
-
- const handleKeyDown = (event) => {
- const currentIndex = tabList.findIndex((item) => item.value === tab);
- if (event.key === "Tab") {
- const nextIndex = (currentIndex + 1) % tabList.length;
- setFocusedTab(tabList[nextIndex].value);
- } else if (event.key === "Enter") {
- event.preventDefault();
- navigate(`/account/${focusedTab}`);
- }
- };
-
- const handleFocus = (tabName) => {
- setFocusedTab(tabName);
- };
-
- return (
-
-
-
- {tabList.map((item, index) => (
- handleFocus(item.value)}
- tabIndex={index}
- />
- ))}
-
-
-
- {!hideTeams && }
-
-
- );
-};
-
-Account.propTypes = {
- open: PropTypes.oneOf(["profile", "password", "team"]),
-};
-
-export default Account;
diff --git a/client/src/Pages/Account/index.tsx b/client/src/Pages/Account/index.tsx
new file mode 100644
index 000000000..b0fd6554e
--- /dev/null
+++ b/client/src/Pages/Account/index.tsx
@@ -0,0 +1,39 @@
+import { BasePage, Tabs, Tab } from "@/Components/v2/design-elements";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { TabProfile } from "./TabProfile";
+import { TabPassword } from "./TabPassword";
+import { TabTeam } from "./TabTeam";
+
+interface AccountProps {
+ open?: "profile" | "password" | "team";
+}
+
+const TAB_MAP = {
+ profile: 0,
+ password: 1,
+ team: 2,
+} as const;
+
+const Account = ({ open = "profile" }: AccountProps) => {
+ const { t } = useTranslation();
+ const [activeTab, setActiveTab] = useState(TAB_MAP[open]);
+
+ return (
+
+ setActiveTab(newValue)}
+ >
+
+
+
+
+ {activeTab === 0 && }
+ {activeTab === 1 && }
+ {activeTab === 2 && }
+
+ );
+};
+
+export default Account;
diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx
index 936258595..282a5db56 100644
--- a/client/src/Routes/index.jsx
+++ b/client/src/Routes/index.jsx
@@ -45,7 +45,7 @@ import Notifications from "../Pages/Notifications";
import CreateNotifications from "../Pages/Notifications/create";
// Settings
-import Account from "../Pages/Account/index.jsx";
+import Account from "@/Pages/Account";
import EditUser from "../Pages/Account/EditUser/index.jsx";
import Settings from "../Pages/Settings";
@@ -328,15 +328,27 @@ const Routes = () => {
/>
}
+ element={
+
+
+
+ }
/>
}
+ element={
+
+
+
+ }
/>
}
+ element={
+
+
+
+ }
/>
} The response from the axios PUT request.
+ * @returns {Promise} The response from the axios PATCH request.
*
*/
async updateUser(config) {
diff --git a/client/src/Validation/addTeamMember.ts b/client/src/Validation/addTeamMember.ts
new file mode 100644
index 000000000..bada2beb4
--- /dev/null
+++ b/client/src/Validation/addTeamMember.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+import { registerSchema } from "@/Validation/register";
+import { UserRoles } from "@/Types/User";
+
+export const addTeamMemberSchema = registerSchema.extend({
+ role: z.array(z.enum(UserRoles)).min(1, "Please select a role"),
+});
+
+export type AddTeamMemberFormData = z.infer;
diff --git a/client/src/Validation/invite.ts b/client/src/Validation/invite.ts
new file mode 100644
index 000000000..fecbc0158
--- /dev/null
+++ b/client/src/Validation/invite.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+import { UserRoles } from "@/Types/User";
+
+export const inviteSchema = z.object({
+ email: z.email("Please enter a valid email address"),
+ role: z.array(z.enum(UserRoles)).min(1, "Please select a role"),
+});
+
+export type InviteFormData = z.infer;
diff --git a/client/src/Validation/password.ts b/client/src/Validation/password.ts
new file mode 100644
index 000000000..c287fcb25
--- /dev/null
+++ b/client/src/Validation/password.ts
@@ -0,0 +1,25 @@
+import { z } from "zod";
+import { specialCharPattern } from "@/Validation/patterns";
+
+export const passwordSchema = z
+ .object({
+ currentPassword: z.string().min(1, "Current password is required"),
+ newPassword: z
+ .string()
+ .min(1, "New password is required")
+ .min(8, "Password must be at least 8 characters")
+ .refine((val) => /[A-Z]/.test(val), "Password must contain an uppercase letter")
+ .refine((val) => /[a-z]/.test(val), "Password must contain a lowercase letter")
+ .refine((val) => /\d/.test(val), "Password must contain a number")
+ .refine(
+ (val) => specialCharPattern.test(val),
+ "Password must contain a special character"
+ ),
+ confirm: z.string().min(1, "Please confirm your new password"),
+ })
+ .refine((data) => data.newPassword === data.confirm, {
+ message: "Passwords do not match",
+ path: ["confirm"],
+ });
+
+export type PasswordFormData = z.infer;
diff --git a/client/src/Validation/patterns.ts b/client/src/Validation/patterns.ts
new file mode 100644
index 000000000..d1e20fa73
--- /dev/null
+++ b/client/src/Validation/patterns.ts
@@ -0,0 +1,4 @@
+export const specialCharPattern = /[!?@#$%^&*()\-_=+[\]{};:'",.<>~`|\\/]/;
+
+// Unicode-aware name pattern (allows letters, marks, apostrophes, parentheses, hyphens, periods, spaces)
+export const namePattern = /^[\p{L}\p{M}''()\-\. ]+$/u;
diff --git a/client/src/Validation/profile.ts b/client/src/Validation/profile.ts
new file mode 100644
index 000000000..eca0ddf5c
--- /dev/null
+++ b/client/src/Validation/profile.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+import { namePattern } from "@/Validation/patterns";
+
+export const profileSchema = 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"),
+ profileImage: z.instanceof(File).optional().nullable(),
+ deleteProfileImage: z.boolean().optional(),
+});
+
+export type ProfileFormData = z.infer;
diff --git a/client/src/Validation/register.ts b/client/src/Validation/register.ts
index 001dfa56a..d76c5f37e 100644
--- a/client/src/Validation/register.ts
+++ b/client/src/Validation/register.ts
@@ -1,7 +1,5 @@
import { z } from "zod";
-
-const namePattern = /^[\p{L}\p{M}''()\-\. ]+$/u;
-const specialCharPattern = /[!?@#$%^&*()\-_=+[\]{};:'",.<>~`|\\/]/;
+import { specialCharPattern, namePattern } from "@/Validation/patterns";
export const registerSchema = z
.object({
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 305b3cf90..69684af6a 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -167,6 +167,7 @@
"uploadSuccess": "Monitors created successfully!"
},
"bytesSent": "Bytes Sent",
+ "addMember": "Add member",
"cancel": "Cancel",
"checkHooks": {
"failureResolveOne": "Failed to resolve incident."
@@ -176,6 +177,14 @@
"ClickUpload": "Click to upload",
"close": "Close",
"common": {
+ "auth": {
+ "roles": {
+ "admin": "Admin",
+ "demo": "Demo",
+ "superadmin": "Super admin",
+ "user": "User"
+ }
+ },
"alerts": {
"pageSpeedApiKey": {
"content": "Warning: You haven't added a Google PageSpeed API key yet. Visit Settings to add one. Without it, the PageSpeed monitor won't function."
@@ -187,16 +196,20 @@
"home": "Home"
},
"buttons": {
+ "addMember": "Add member",
"cancel": "Cancel",
"close": "Close",
"configure": "Configure",
"confirm": "Confirm",
"create": "Create",
"delete": "Delete",
+ "generateToken": "Generate token",
"incidents": "Incidents",
+ "inviteMember": "Invite member",
"pause": "Pause",
"resume": "Resume",
"save": "Save",
+ "sendInvite": "Send invite",
"test": "Test",
"testNotifications": "Test notifications",
"toggleTheme": "Toggles light & dark",
@@ -291,6 +304,7 @@
"createMonitor": "Create monitor",
"createNew": "Create new",
"delete": "Delete",
+ "generateToken": "Generate token",
"DeleteAccountButton": "Remove account",
"DeleteAccountTitle": "Remove account",
"DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.",
@@ -451,6 +465,7 @@
"discussions": "Discussions",
"docs": "Docs",
"incidents": "Incidents",
+ "inviteMember": "Invite member",
"infrastructure": "Infrastructure",
"logOut": "Log out",
"logs": "Logs",
@@ -498,6 +513,91 @@
"now": "Now",
"os": "OS",
"pages": {
+ "account": {
+ "tabs": {
+ "profile": "Profile",
+ "password": "Password",
+ "team": "Team"
+ },
+ "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"
+ }
+ }
+ },
+ "photo": {
+ "title": "Profile photo",
+ "description": "Upload a profile picture"
+ },
+ "currentPassword": {
+ "title": "Current password",
+ "description": "Enter your current password to verify your identity",
+ "option": {
+ "label": "Current password",
+ "placeholder": "Enter current password"
+ }
+ },
+ "newPassword": {
+ "title": "New password",
+ "description": "Choose a strong password with at least 8 characters",
+ "option": {
+ "newPassword": {
+ "label": "New password",
+ "placeholder": "Enter new password"
+ },
+ "confirm": {
+ "label": "Confirm password",
+ "placeholder": "Confirm new password"
+ }
+ }
+ },
+ "deleteAccount": {
+ "title": "Delete account",
+ "description": "This action is permanent and cannot be undone"
+ }
+ },
+ "team": {
+ "filter": {
+ "placeholder": "Filter by role",
+ "all": "All roles",
+ "admin": "Admin",
+ "member": "Member"
+ },
+ "table": {
+ "headers": {
+ "email": "Email",
+ "role": "Role",
+ "created": "Created at"
+ }
+ },
+ "addMember": {
+ "title": "Register new team member",
+ "description": "Create a new user account. Share credentials securely after creation."
+ },
+ "invite": {
+ "title": "Invite team member",
+ "description": "Send an invitation to join your team",
+ "email": {
+ "label": "Email address",
+ "placeholder": "Enter email address"
+ },
+ "role": {
+ "label": "Role",
+ "placeholder": "Select a role"
+ },
+ "linkLabel": "Invite link"
+ }
+ }
+ },
"auth": {
"common": {
"form": {
@@ -584,8 +684,10 @@
"actions": {
"configure": "Configure",
"delete": "Delete",
+ "generateToken": "Generate token",
"details": "Details",
"incidents": "Incidents",
+ "inviteMember": "Invite member",
"openSite": "Open site",
"pause": "Pause",
"resume": "Resume"
@@ -1250,6 +1352,7 @@
"teamMember": "Team member"
},
"save": "Save",
+ "sendInvite": "Send invite",
"selectAll": "Select all",
"settingsFailedToClearStats": "Failed to clear stats",
"settingsFailedToDeleteMonitors": "Failed to delete all monitors",
@@ -1376,6 +1479,7 @@
"description": "Create a new user and share the credentials with them. This method gives the member immediate access to all monitors.",
"title": "Register new team member"
},
+ "addMember": "Add member",
"cancel": "Cancel",
"changeTeamPassword": {
"changePasswordMenu": "Reset Password",
diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts
index 165bec056..3fa551808 100755
--- a/server/src/controllers/authController.ts
+++ b/server/src/controllers/authController.ts
@@ -1,10 +1,13 @@
import { Request, Response, NextFunction } from "express";
import { AppError } from "@/utils/AppError.js";
+import { requireTeamId, requireUserRoles } from "./controllerUtils.js";
+import type { UserRole } from "@/types/user.js";
import {
registrationBodyValidation,
loginValidation,
editUserBodyValidation,
+ createUserBodyValidation,
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
@@ -50,6 +53,27 @@ class AuthController {
}
};
+ createUser = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const userData = req.body;
+ if (userData?.email) {
+ userData.email = userData.email.toLowerCase();
+ }
+ await createUserBodyValidation.validateAsync(userData);
+
+ const teamId = requireTeamId(req.user?.teamId);
+ const actorRoles = requireUserRoles(req.user?.role) as UserRole[];
+ const newUser = await this.userService.createUser(userData, teamId, actorRoles, req.file);
+ res.status(201).json({
+ success: true,
+ msg: "User created successfully",
+ data: newUser,
+ });
+ } catch (error) {
+ next(error);
+ }
+ };
+
loginUser = async (req: Request, res: Response, next: NextFunction) => {
try {
if (req.body?.email) {
@@ -74,7 +98,6 @@ class AuthController {
editUser = async (req: Request, res: Response, next: NextFunction) => {
try {
await editUserBodyValidation.validateAsync(req.body);
-
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
res.status(200).json({
diff --git a/server/src/controllers/inviteController.ts b/server/src/controllers/inviteController.ts
index e6d5d7ccb..bba04fbbd 100755
--- a/server/src/controllers/inviteController.ts
+++ b/server/src/controllers/inviteController.ts
@@ -15,7 +15,6 @@ class InviteController {
}
getInviteToken = async (req: Request, res: Response, next: NextFunction) => {
- console.log(req.body);
try {
const teamId = requireTeamId(req.user?.teamId);
const userRoles = requireUserRoles(req.user?.role);
diff --git a/server/src/db/models/User.ts b/server/src/db/models/User.ts
index 6aa3835a6..ac3895301 100644
--- a/server/src/db/models/User.ts
+++ b/server/src/db/models/User.ts
@@ -50,24 +50,6 @@ const UserSchema = new Schema(
{ timestamps: true }
);
-UserSchema.pre("save", function (next) {
- if (!this.isModified("password")) {
- return next();
- }
- const salt = bcrypt.genSaltSync(10);
- this.password = bcrypt.hashSync(this.password, salt);
- next();
-});
-
-UserSchema.pre("findOneAndUpdate", function (next) {
- const update = this.getUpdate();
- if (update && "password" in update) {
- const salt = bcrypt.genSaltSync(10);
- (update as any).password = bcrypt.hashSync((update as any).password, salt);
- }
- next();
-});
-
UserSchema.pre("findOneAndDelete", async function (next) {
try {
const userToDelete = await this.model.findOne(this.getFilter());
diff --git a/server/src/repositories/users/MongoUsersRepository.ts b/server/src/repositories/users/MongoUsersRepository.ts
index 023e8aefb..fde134a33 100644
--- a/server/src/repositories/users/MongoUsersRepository.ts
+++ b/server/src/repositories/users/MongoUsersRepository.ts
@@ -98,10 +98,11 @@ class MongoUsersRepository implements IUsersRepository {
updateById = async (id: string, patch: Partial, file?: Express.Multer.File | null): Promise => {
const candidateUser = { ...patch };
+ let unsetFields: Record | undefined;
if (ParseBoolean(candidateUser.deleteProfileImage) === true) {
- candidateUser.profileImage = undefined;
- candidateUser.avatarImage = undefined;
+ unsetFields = { profileImage: 1, avatarImage: 1 };
+ delete candidateUser.deleteProfileImage;
} else if (file) {
// 1. Save the full size image
candidateUser.profileImage = {
@@ -114,13 +115,14 @@ class MongoUsersRepository implements IUsersRepository {
candidateUser.avatarImage = avatar;
}
- const updatedUser = await UserModel.findOneAndUpdate(
- { _id: id },
- candidateUser,
- { new: true } // Returns updated user instead of pre-update user
- )
- .select("-password")
- .select("-profileImage");
+ delete candidateUser.deleteProfileImage;
+
+ const updateQuery: Record = { $set: candidateUser };
+ if (unsetFields) {
+ updateQuery.$unset = unsetFields;
+ }
+
+ const updatedUser = await UserModel.findOneAndUpdate({ _id: id }, updateQuery, { new: true }).select("-password").select("-profileImage");
if (!updatedUser) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts
index eec6ea11b..cc1ec3fd6 100755
--- a/server/src/routes/authRoute.ts
+++ b/server/src/routes/authRoute.ts
@@ -25,11 +25,12 @@ class AuthRoutes {
this.router.get("/users/superadmin", this.authController.checkSuperadminExists);
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
+ this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser);
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("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
+ this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
+ this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
- this.router.put("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
+ this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
}
diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts
index 281c03ab7..d05f027b1 100644
--- a/server/src/service/business/userService.ts
+++ b/server/src/service/business/userService.ts
@@ -7,6 +7,7 @@ import {
ITeamsRepository,
} from "@/repositories/index.js";
import type { User } from "@/types/index.js";
+import { canManageRole, type UserRole } from "@/types/user.js";
import bcrypt from "bcryptjs";
import { AppError } from "@/utils/AppError.js";
import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
@@ -16,6 +17,11 @@ const SERVICE_NAME = "userService";
class UserService {
static SERVICE_NAME = SERVICE_NAME;
+ private hashPassword = (password: string): string => {
+ const salt = bcrypt.genSaltSync(10);
+ return bcrypt.hashSync(password, salt);
+ };
+
private emailService: any;
private settingsService: any;
private logger: any;
@@ -103,7 +109,12 @@ class UserService {
user.role = ["superadmin"];
}
- const newUser = await this.usersRepository.create({ ...user }, file);
+ // Hash password before storing
+ if (user.password) {
+ user.password = this.hashPassword(user.password);
+ }
+
+ const newUser = await this.usersRepository.create(user, file);
this.logger.debug({
message: "New user created",
@@ -143,6 +154,43 @@ class UserService {
return { user: newUser, token };
};
+ createUser = async (userData: Partial, teamId: string, actorRoles: UserRole[], file: any) => {
+ // Validate that the creator can assign the requested roles
+ const targetRoles = userData.role ?? [];
+ for (const targetRole of targetRoles) {
+ const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole));
+ if (!canManage) {
+ throw new AppError({
+ message: "You do not have permission to assign this role",
+ service: SERVICE_NAME,
+ method: "createUser",
+ status: 403,
+ });
+ }
+ }
+
+ userData.teamId = teamId;
+
+ if (userData.password) {
+ userData.password = this.hashPassword(userData.password);
+ }
+
+ const newUser = await this.usersRepository.create(userData, file);
+
+ this.logger.debug({
+ message: "New user created by superadmin",
+ service: SERVICE_NAME,
+ method: "createUser",
+ details: newUser.id,
+ });
+
+ newUser.profileImage = undefined;
+ newUser.avatarImage = undefined;
+ newUser.password = "";
+
+ return newUser;
+ };
+
loginUser = async (email: string, password: string) => {
// Check if user exists
const user = await this.usersRepository.findByEmail(email);
@@ -182,7 +230,8 @@ class UserService {
throw new AppError({ message: "Incorrect current password", service: SERVICE_NAME, status: 403 });
}
// If a match, update the password
- updates.password = updates.newPassword;
+ updates.password = this.hashPassword(updates.newPassword);
+ delete updates.newPassword;
}
return await this.usersRepository.updateById(currentUser.id, updates, file);
@@ -225,8 +274,8 @@ class UserService {
throw new AppError({ message: "New password cannot be same as old password", service: SERVICE_NAME, status: 400 });
}
- existingUser.password = password;
- await this.usersRepository.updateById(existingUser.id, existingUser, null);
+ const hashedPassword = this.hashPassword(password);
+ await this.usersRepository.updateById(existingUser.id, { password: hashedPassword }, null);
await this.recoveryTokensRepository.deleteManyByEmail(existingUser.email);
existingUser.password = "";
@@ -294,7 +343,8 @@ class UserService {
};
setPasswordByUserId = async (userId: any, password: string) => {
- const updatedUser = await this.usersRepository.updateById(userId, { password }, null);
+ const hashedPassword = this.hashPassword(password);
+ const updatedUser = await this.usersRepository.updateById(userId, { password: hashedPassword }, null);
return updatedUser;
};
}
diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts
index c1e945961..f01a8da40 100755
--- a/server/src/validation/joi.ts
+++ b/server/src/validation/joi.ts
@@ -60,7 +60,7 @@ const editUserBodyValidation = joi.object({
profileImage: joi.any(),
newPassword: joi.string().min(8).pattern(passwordPattern),
password: joi.string().min(8).pattern(passwordPattern),
- deleteProfileImage: joi.boolean(),
+ deleteProfileImage: joi.alternatives().try(joi.boolean(), joi.string().valid("true", "false")),
});
const recoveryValidation = joi.object({
@@ -712,6 +712,28 @@ const editUserPasswordByIdBodyValidation = joi.object({
password: joi.string().min(8).required().pattern(passwordPattern),
});
+const createUserBodyValidation = joi.object({
+ firstName: nameValidation.required(),
+ lastName: nameValidation.required(),
+ email: joi
+ .string()
+ .email()
+ .required()
+ .custom((value, helpers) => {
+ const lowercasedValue = value.toLowerCase();
+ if (value !== lowercasedValue) {
+ return helpers.message({ custom: "Email must be in lowercase" });
+ }
+ return lowercasedValue;
+ }),
+ password: joi.string().min(8).required().pattern(passwordPattern),
+ role: joi
+ .array()
+ .items(joi.string().valid(...UserRoles))
+ .min(1)
+ .required(),
+});
+
export {
roleValidatior,
loginValidation,
@@ -781,4 +803,5 @@ export {
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
+ createUserBodyValidation,
};