From 19b370d89bc5ca0b56589dec2d18dd6a7da46514 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 00:49:49 +0000 Subject: [PATCH 01/21] initial commit --- client/src/Pages/Account/TabPassword.tsx | 5 +++ client/src/Pages/Account/TabProfile.tsx | 44 +++++++++++++++++++ client/src/Pages/Account/TabTeam.tsx | 5 +++ client/src/Pages/Account/index.tsx | 39 ++++++++++++++++ .../src/Pages/Account/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 20 +++++++-- client/src/locales/en.json | 31 +++++++++++++ 7 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 client/src/Pages/Account/TabPassword.tsx create mode 100644 client/src/Pages/Account/TabProfile.tsx create mode 100644 client/src/Pages/Account/TabTeam.tsx create mode 100644 client/src/Pages/Account/index.tsx rename client/src/Pages/Account/{index.jsx => old.jsx} (100%) diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx new file mode 100644 index 000000000..0acecd35a --- /dev/null +++ b/client/src/Pages/Account/TabPassword.tsx @@ -0,0 +1,5 @@ +import { Box } from "@mui/material"; + +export const TabPassword = () => { + return {/* TODO: Implement TabPassword */}; +}; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx new file mode 100644 index 000000000..5cc21cb9a --- /dev/null +++ b/client/src/Pages/Account/TabProfile.tsx @@ -0,0 +1,44 @@ +import { Stack } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { ConfigBox } from "@/Components/v2/design-elements"; +import { TextField, Button } from "@/Components/v2/inputs"; +import { ImageUpload } from "@/Components/v2/inputs"; + +export const TabProfile = () => { + const { t } = useTranslation(); + + return ( + + + + + + } + /> + } + /> + + + ); +}; diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx new file mode 100644 index 000000000..0babcad86 --- /dev/null +++ b/client/src/Pages/Account/TabTeam.tsx @@ -0,0 +1,5 @@ +import { Box } from "@mui/material"; + +export const TabTeam = () => { + return {/* TODO: Implement TabTeam */}; +}; 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/Pages/Account/index.jsx b/client/src/Pages/Account/old.jsx similarity index 100% rename from client/src/Pages/Account/index.jsx rename to client/src/Pages/Account/old.jsx 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={ + + + + } /> Date: Sat, 7 Feb 2026 00:51:04 +0000 Subject: [PATCH 02/21] fix strings --- client/src/Pages/Account/TabProfile.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 5cc21cb9a..6fccfc851 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -10,26 +10,26 @@ export const TabProfile = () => { return ( } /> } /> + + + + + ); +}; diff --git a/client/src/Hooks/useProfileForm.ts b/client/src/Hooks/useProfileForm.ts new file mode 100644 index 000000000..e426f904e --- /dev/null +++ b/client/src/Hooks/useProfileForm.ts @@ -0,0 +1,14 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { profileSchema, type ProfileFormData } from "@/Validation/profile"; + +export const useProfileForm = () => { + return { + resolver: zodResolver(profileSchema), + defaults: { + firstName: "", + lastName: "", + profileImage: null, + deleteProfileImage: false, + } as ProfileFormData, + }; +}; diff --git a/client/src/Validation/profile.ts b/client/src/Validation/profile.ts new file mode 100644 index 000000000..56301c31f --- /dev/null +++ b/client/src/Validation/profile.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +const NAME_PATTERN = /^[A-Za-z'-]+$/; + +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(NAME_PATTERN, "First name can only contain letters, hyphens, and apostrophes"), + lastName: z + .string() + .min(1, "Last name is required") + .max(50, "Last name must be 50 characters or less") + .regex(NAME_PATTERN, "Last name can only contain letters, hyphens, and apostrophes"), + profileImage: z.instanceof(File).optional().nullable(), + deleteProfileImage: z.boolean().optional(), +}); + +export type ProfileFormData = z.infer; From 4945b293340d6bc5cdd1a9bfa945b11984ba865a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 01:13:58 +0000 Subject: [PATCH 04/21] wire up form --- client/src/Features/Auth/authSlice.js | 5 +- client/src/Pages/Account/TabProfile.tsx | 181 +++++++++++++++++++----- client/src/Types/state.ts | 4 +- 3 files changed, 156 insertions(+), 34 deletions(-) diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js index 1d93ce3fa..d6c63b6a9 100644 --- a/client/src/Features/Auth/authSlice.js +++ b/client/src/Features/Auth/authSlice.js @@ -203,6 +203,9 @@ const authSlice = createSlice({ state.authToken = action.payload.data.token; state.user = action.payload.data.user; }, + setUser: (state, action) => { + state.user = action.payload; + }, }, extraReducers: (builder) => { // Register thunk @@ -256,4 +259,4 @@ const authSlice = createSlice({ }); export default authSlice.reducer; -export const { clearAuthState, setAuthState } = authSlice.actions; +export const { clearAuthState, setAuthState, setUser } = authSlice.actions; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 6fccfc851..9108e8c32 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,44 +1,161 @@ -import { Stack } from "@mui/material"; +import { useState } from "react"; +import { Stack, Box } from "@mui/material"; import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; +import { useSelector, useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; import { ConfigBox } from "@/Components/v2/design-elements"; import { TextField, Button } from "@/Components/v2/inputs"; import { ImageUpload } from "@/Components/v2/inputs"; +import { useProfileForm } from "@/Hooks/useProfileForm"; +import { usePatch, useDelete } from "@/Hooks/UseApi"; +import { setUser, clearAuthState } from "@/Features/Auth/authSlice"; +import type { ProfileFormData } from "@/Validation/profile"; +import type { RootState } from "@/Types/state"; +import type { User } from "@/Types/User"; export const TabProfile = () => { const { t } = useTranslation(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const user = useSelector((state: RootState) => state.auth?.user); + const { resolver, defaults } = useProfileForm(); + const { patch, loading: patchLoading } = usePatch(); + const { deleteFn, loading: deleteLoading } = useDelete(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { control, handleSubmit, setValue, watch } = useForm({ + resolver, + defaultValues: { + firstName: user?.firstName ?? defaults.firstName, + lastName: user?.lastName ?? defaults.lastName, + profileImage: defaults.profileImage, + deleteProfileImage: defaults.deleteProfileImage, + }, + }); + + const currentImage = watch("profileImage"); + const deleteImage = watch("deleteProfileImage"); + + const getCurrentImageSrc = () => { + if (deleteImage) return undefined; + if (currentImage) return URL.createObjectURL(currentImage); + if (user?.avatarImage) return `data:image/png;base64,${user.avatarImage}`; + return undefined; + }; + + const handleImageChange = ( + fileObj: { src: string; name: string; file: File } | undefined + ) => { + if (fileObj) { + setValue("profileImage", fileObj.file); + setValue("deleteProfileImage", false); + } else { + setValue("profileImage", null); + setValue("deleteProfileImage", true); + } + }; + + const onSubmit = async (data: ProfileFormData) => { + const fd = new FormData(); + fd.append("firstName", data.firstName); + fd.append("lastName", data.lastName); + + if (data.profileImage) { + fd.append("profileImage", data.profileImage); + } + + if (data.deleteProfileImage) { + fd.append("deleteProfileImage", "true"); + } + + console.log(fd); + + // const result = await patch("/auth/user", fd); + // if (result?.success && result.data) { + // dispatch(setUser(result.data)); + // } + }; + + const handleDeleteAccount = async () => { + const result = await deleteFn("/auth/user"); + if (result?.success) { + dispatch(clearAuthState()); + navigate("/login"); + } + }; + + const showDeleteSection = !user?.role?.includes("demo"); return ( - - - - - - } - /> - } - /> - - + + + ( + + )} + /> + ( + + )} + /> + + } + /> + + } + /> + + + + ); }; diff --git a/client/src/Types/state.ts b/client/src/Types/state.ts index 1575fffb8..b5bfcfdb8 100644 --- a/client/src/Types/state.ts +++ b/client/src/Types/state.ts @@ -1,7 +1,9 @@ +import type { User } from "./User"; + export interface AuthState { isLoading: boolean; authToken: string; - user: string; + user: User | null; success: boolean | null; msg: string | null; } From 4f4203dbb32f4746f870b7f5241a37d2caaa35b5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 19:59:28 +0000 Subject: [PATCH 05/21] remove delete --- client/src/Pages/Account/TabProfile.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 9108e8c32..8a9d04068 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -77,16 +77,6 @@ export const TabProfile = () => { // } }; - const handleDeleteAccount = async () => { - const result = await deleteFn("/auth/user"); - if (result?.success) { - dispatch(clearAuthState()); - navigate("/login"); - } - }; - - const showDeleteSection = !user?.role?.includes("demo"); - return ( <> Date: Sat, 7 Feb 2026 20:11:30 +0000 Subject: [PATCH 06/21] submit --- .../Components/v2/design-elements/index.tsx | 1 + client/src/Pages/Account/TabProfile.tsx | 19 ++++++------------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index b7b1d484e..ebf78c61f 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -19,3 +19,4 @@ export * from "./Gauge"; export * from "./Tabs"; export * from "./SplitBox"; export * from "./TextLink"; +export * from "./ConfirmDialog"; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 8a9d04068..540a5b8b5 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,15 +1,13 @@ -import { useState } from "react"; import { Stack, Box } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useForm, Controller } from "react-hook-form"; import { useSelector, useDispatch } from "react-redux"; -import { useNavigate } from "react-router-dom"; import { ConfigBox } from "@/Components/v2/design-elements"; import { TextField, Button } from "@/Components/v2/inputs"; import { ImageUpload } from "@/Components/v2/inputs"; import { useProfileForm } from "@/Hooks/useProfileForm"; -import { usePatch, useDelete } from "@/Hooks/UseApi"; -import { setUser, clearAuthState } from "@/Features/Auth/authSlice"; +import { usePatch } from "@/Hooks/UseApi"; +import { setUser } from "@/Features/Auth/authSlice"; import type { ProfileFormData } from "@/Validation/profile"; import type { RootState } from "@/Types/state"; import type { User } from "@/Types/User"; @@ -17,12 +15,9 @@ import type { User } from "@/Types/User"; export const TabProfile = () => { const { t } = useTranslation(); const dispatch = useDispatch(); - const navigate = useNavigate(); const user = useSelector((state: RootState) => state.auth?.user); const { resolver, defaults } = useProfileForm(); const { patch, loading: patchLoading } = usePatch(); - const { deleteFn, loading: deleteLoading } = useDelete(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { control, handleSubmit, setValue, watch } = useForm({ resolver, @@ -69,12 +64,10 @@ export const TabProfile = () => { fd.append("deleteProfileImage", "true"); } - console.log(fd); - - // const result = await patch("/auth/user", fd); - // if (result?.success && result.data) { - // dispatch(setUser(result.data)); - // } + const result = await patch("/auth/user", fd); + if (result?.success && result.data) { + dispatch(setUser(result.data)); + } }; return ( From f2b740a8f910f8713582b32f86c6015195ef7f82 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 20:17:59 +0000 Subject: [PATCH 07/21] put -> patch --- .../v2/design-elements/ConfirmDialog.tsx | 105 ------------------ .../Components/v2/design-elements/index.tsx | 1 - client/src/Utils/NetworkService.js | 2 +- server/src/routes/authRoute.ts | 6 +- 4 files changed, 4 insertions(+), 110 deletions(-) delete mode 100644 client/src/Components/v2/design-elements/ConfirmDialog.tsx diff --git a/client/src/Components/v2/design-elements/ConfirmDialog.tsx b/client/src/Components/v2/design-elements/ConfirmDialog.tsx deleted file mode 100644 index 1fe4d7b89..000000000 --- a/client/src/Components/v2/design-elements/ConfirmDialog.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useId } from "react"; -import { Modal, Stack, Typography, Box } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; -import { Button } from "@/Components/v2/inputs"; -import { useTranslation } from "react-i18next"; - -interface ConfirmDialogProps { - open: boolean; - onClose: () => void; - title: string; - description?: string; - onConfirm: () => void; - confirmText?: string; - cancelText?: string; - isLoading?: boolean; - confirmColor?: "error" | "primary" | "secondary" | "success" | "warning" | "info"; -} - -export const ConfirmDialog = ({ - open, - onClose, - title, - description, - onConfirm, - confirmText, - cancelText, - isLoading = false, - confirmColor = "error", -}: ConfirmDialogProps) => { - const theme = useTheme(); - const { t } = useTranslation(); - const titleId = useId(); - const descriptionId = useId(); - - return ( - e.stopPropagation()} - > - - - {title} - - {description && ( - - {description} - - )} - - - - - - - ); -}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index ebf78c61f..b7b1d484e 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -19,4 +19,3 @@ export * from "./Gauge"; export * from "./Tabs"; export * from "./SplitBox"; export * from "./TextLink"; -export * from "./ConfirmDialog"; diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index d9b151a10..7f6a8e455 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -374,7 +374,7 @@ class NetworkService { * @param {Object} config - The configuration object. * @param {string} config.userId - The ID of the user to be updated. * @param {Object} config.form - The form data for the user to be updated. - * @returns {Promise} The response from the axios PUT request. + * @returns {Promise} The response from the axios PATCH request. * */ async updateUser(config) { diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts index eec6ea11b..97e85d61f 100755 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -26,10 +26,10 @@ class AuthRoutes { 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("/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); } From af4c9d8f91c7398951e032d05b5daf17917e35b0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 20:33:11 +0000 Subject: [PATCH 08/21] fix unset --- client/src/Pages/Account/TabProfile.tsx | 137 +++++++++--------- server/src/controllers/authController.ts | 1 - .../users/MongoUsersRepository.ts | 20 +-- server/src/validation/joi.ts | 2 +- 4 files changed, 78 insertions(+), 82 deletions(-) diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 540a5b8b5..6b05e0082 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,10 +1,12 @@ import { Stack, Box } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; -import { useSelector, useDispatch } from "react-redux"; import { ConfigBox } from "@/Components/v2/design-elements"; import { TextField, Button } from "@/Components/v2/inputs"; import { ImageUpload } from "@/Components/v2/inputs"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; +import { useSelector, useDispatch } from "react-redux"; import { useProfileForm } from "@/Hooks/useProfileForm"; import { usePatch } from "@/Hooks/UseApi"; import { setUser } from "@/Features/Auth/authSlice"; @@ -13,6 +15,7 @@ import type { RootState } from "@/Types/state"; import type { User } from "@/Types/User"; export const TabProfile = () => { + const theme = useTheme(); const { t } = useTranslation(); const dispatch = useDispatch(); const user = useSelector((state: RootState) => state.auth?.user); @@ -71,74 +74,66 @@ export const TabProfile = () => { }; return ( - <> - + + ( + + )} + /> + ( + + )} + /> + + } + /> + + } + /> + - - - + {t("common.buttons.save")} + + ); }; diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 165bec056..d6fe6aeff 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -74,7 +74,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/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/validation/joi.ts b/server/src/validation/joi.ts index c1e945961..f72addc5e 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({ From b6004241e7cb72504e8d51ef127168318f92e076 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 20:54:17 +0000 Subject: [PATCH 09/21] validation --- client/src/Hooks/usePasswordForm.ts | 13 +++++++ client/src/Pages/Account/TabPassword.tsx | 46 ++++++++++++++++++++++-- client/src/Pages/Account/TabProfile.tsx | 2 +- client/src/Validation/password.ts | 25 +++++++++++++ client/src/Validation/patterns.ts | 4 +++ client/src/Validation/profile.ts | 7 ++-- client/src/Validation/register.ts | 4 +-- client/src/locales/en.json | 22 ++++++++++++ 8 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 client/src/Hooks/usePasswordForm.ts create mode 100644 client/src/Validation/password.ts create mode 100644 client/src/Validation/patterns.ts diff --git a/client/src/Hooks/usePasswordForm.ts b/client/src/Hooks/usePasswordForm.ts new file mode 100644 index 000000000..d38f73abf --- /dev/null +++ b/client/src/Hooks/usePasswordForm.ts @@ -0,0 +1,13 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { passwordSchema, type PasswordFormData } from "@/Validation/password"; + +export const usePasswordForm = () => { + return { + resolver: zodResolver(passwordSchema), + defaults: { + currentPassword: "", + newPassword: "", + confirm: "", + } as PasswordFormData, + }; +}; diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index 0acecd35a..cd07410ac 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -1,5 +1,47 @@ -import { Box } from "@mui/material"; +import { Stack } from "@mui/material"; +import { ConfigBox } from "@/Components/v2/design-elements"; +import { TextField } from "@/Components/v2/inputs"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; export const TabPassword = () => { - return {/* TODO: Implement TabPassword */}; + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + + + } + /> + + + + + } + /> + + ); }; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 6b05e0082..8ea8a50a0 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,4 +1,4 @@ -import { Stack, Box } from "@mui/material"; +import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/v2/design-elements"; import { TextField, Button } from "@/Components/v2/inputs"; import { ImageUpload } from "@/Components/v2/inputs"; 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 index 56301c31f..eca0ddf5c 100644 --- a/client/src/Validation/profile.ts +++ b/client/src/Validation/profile.ts @@ -1,18 +1,17 @@ import { z } from "zod"; - -const NAME_PATTERN = /^[A-Za-z'-]+$/; +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(NAME_PATTERN, "First name can only contain letters, hyphens, and apostrophes"), + .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(NAME_PATTERN, "Last name can only contain letters, hyphens, and apostrophes"), + .regex(namePattern, "Last name contains invalid characters"), profileImage: z.instanceof(File).optional().nullable(), deleteProfileImage: z.boolean().optional(), }); 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 21a8e8e0a..451c59e17 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -523,6 +523,28 @@ "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" From 021b4e8ae50a72c7418a727cc4fb5f8c727358b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 20:57:03 +0000 Subject: [PATCH 10/21] controllers --- client/src/Pages/Account/TabPassword.tsx | 89 ++++++++++++++++++++---- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index cd07410ac..cdf7ce4c4 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -1,24 +1,51 @@ import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/v2/design-elements"; -import { TextField } from "@/Components/v2/inputs"; +import { TextField, Button } from "@/Components/v2/inputs"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; +import { useForm, Controller } from "react-hook-form"; +import { usePasswordForm } from "@/Hooks/usePasswordForm"; +import type { PasswordFormData } from "@/Validation/password"; export const TabPassword = () => { const { t } = useTranslation(); const theme = useTheme(); + const { resolver, defaults } = usePasswordForm(); + + const { control, handleSubmit } = useForm({ + resolver, + defaultValues: defaults, + }); + + const onSubmit = (data: PasswordFormData) => { + console.log("Password form data:", data); + }; return ( - + ( + + )} /> } /> @@ -27,21 +54,53 @@ export const TabPassword = () => { subtitle={t("pages.account.form.newPassword.description")} rightContent={ - ( + )} /> - ( + + )} /> } /> + ); }; From 7e527b8eb211fbc19a7ba2318945157176fd3864 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 21:05:45 +0000 Subject: [PATCH 11/21] submit --- client/src/Pages/Account/TabPassword.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index cdf7ce4c4..6adcb99c4 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -6,20 +6,29 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; import { useForm, Controller } from "react-hook-form"; import { usePasswordForm } from "@/Hooks/usePasswordForm"; +import { usePatch } from "@/Hooks/UseApi"; import type { PasswordFormData } from "@/Validation/password"; export const TabPassword = () => { const { t } = useTranslation(); const theme = useTheme(); const { resolver, defaults } = usePasswordForm(); + const { patch, loading } = usePatch(); - const { control, handleSubmit } = useForm({ + const { control, handleSubmit, reset, setError } = useForm({ resolver, defaultValues: defaults, }); - const onSubmit = (data: PasswordFormData) => { - console.log("Password form data:", data); + const onSubmit = async (data: PasswordFormData) => { + const fd = new FormData(); + fd.append("password", data.currentPassword); + fd.append("newPassword", data.newPassword); + + const result = await patch("/auth/user", fd); + if (result?.success) { + reset(); + } }; return ( @@ -97,6 +106,7 @@ export const TabPassword = () => { type="submit" variant="contained" color="primary" + loading={loading} sx={{ alignSelf: "flex-end", minWidth: 100 }} > {t("common.buttons.save")} From 2e3b32462f89081317fc87deaa5a7941dea92840 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 21:15:58 +0000 Subject: [PATCH 12/21] remove mongo hash middleware --- server/src/db/models/User.ts | 18 ------------------ server/src/service/business/userService.ts | 22 +++++++++++++++++----- 2 files changed, 17 insertions(+), 23 deletions(-) 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/service/business/userService.ts b/server/src/service/business/userService.ts index 281c03ab7..39b05a737 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -16,6 +16,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 +108,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", @@ -182,7 +192,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 +236,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 +305,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; }; } From 9ca79ea793e434530c5557de0bb8ddfe5b8a05dd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 8 Feb 2026 16:14:35 +0000 Subject: [PATCH 13/21] team initial commit --- client/src/Pages/Account/TabPassword.tsx | 2 +- client/src/Pages/Account/TabTeam.tsx | 30 +++++++- .../Account/components/HeaderTeamControls.tsx | 74 +++++++++++++++++++ .../Pages/Account/components/TeamPanel.jsx | 7 -- .../Pages/Account/components/TeamTable.tsx | 53 +++++++++++++ client/src/locales/en.json | 24 ++++++ 6 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 client/src/Pages/Account/components/HeaderTeamControls.tsx create mode 100644 client/src/Pages/Account/components/TeamTable.tsx diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index 6adcb99c4..85e13fb7e 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -15,7 +15,7 @@ export const TabPassword = () => { const { resolver, defaults } = usePasswordForm(); const { patch, loading } = usePatch(); - const { control, handleSubmit, reset, setError } = useForm({ + const { control, handleSubmit, reset } = useForm({ resolver, defaultValues: defaults, }); diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx index 0babcad86..48a78033c 100644 --- a/client/src/Pages/Account/TabTeam.tsx +++ b/client/src/Pages/Account/TabTeam.tsx @@ -1,5 +1,31 @@ -import { Box } from "@mui/material"; +import { Stack } from "@mui/material"; +import { useTheme } from "@mui/material"; +import { useState, useMemo } from "react"; +import { HeaderTeamControls } from "./components/HeaderTeamControls"; +import { TeamTable } from "./components/TeamTable"; +import { useGet } from "@/Hooks/UseApi"; +import type { User, UserRole } from "@/Types/User"; export const TabTeam = () => { - return {/* TODO: Implement TabTeam */}; + const theme = useTheme(); + const [filter, setFilter] = useState(""); + + const { data: users } = useGet("/auth/users"); + + const filteredUsers = useMemo(() => { + if (!users) return []; + if (!filter) return users; + + return users.filter((u) => u.role.includes(filter)); + }, [users, filter]); + + 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..807a153e4 --- /dev/null +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -0,0 +1,74 @@ +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; +} + +export const HeaderTeamControls = ({ + filter, + onFilterChange, +}: 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 && ( + + )} + {isSuperAdmin && ( + + )} + + ); +}; diff --git a/client/src/Pages/Account/components/TeamPanel.jsx b/client/src/Pages/Account/components/TeamPanel.jsx index 6c658cc60..f7892bd01 100644 --- a/client/src/Pages/Account/components/TeamPanel.jsx +++ b/client/src/Pages/Account/components/TeamPanel.jsx @@ -15,13 +15,6 @@ 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(); diff --git a/client/src/Pages/Account/components/TeamTable.tsx b/client/src/Pages/Account/components/TeamTable.tsx new file mode 100644 index 000000000..5c2455bec --- /dev/null +++ b/client/src/Pages/Account/components/TeamTable.tsx @@ -0,0 +1,53 @@ +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(", ")} + + ), + }, + ]; + + const handleRowClick = (row: User) => { + if (isSuperAdmin) { + navigate(`/account/team/${row.id}`); + } + }; + + return ( + + ); +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 451c59e17..45b329ea0 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -176,6 +176,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." @@ -549,6 +557,22 @@ "title": "Delete account", "description": "This action is permanent and cannot be undone" } + }, + "team": { + "addMember": "Add member", + "inviteMember": "Invite member", + "filter": { + "placeholder": "Filter by role", + "all": "All roles", + "admin": "Admin", + "member": "Member" + }, + "table": { + "headers": { + "email": "Email", + "role": "Role" + } + } } }, "auth": { From 3fc4160b886880343e7ef81115fc615f0aa9cfd0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 8 Feb 2026 16:23:24 +0000 Subject: [PATCH 14/21] add buttons --- client/src/Pages/Account/TabTeam.tsx | 10 +++ .../Account/components/HeaderTeamControls.tsx | 3 + .../components/InviteTeamMemberDialog.tsx | 72 +++++++++++++++++++ client/src/locales/en.json | 14 ++++ 4 files changed, 99 insertions(+) create mode 100644 client/src/Pages/Account/components/InviteTeamMemberDialog.tsx diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx index 48a78033c..308becb2a 100644 --- a/client/src/Pages/Account/TabTeam.tsx +++ b/client/src/Pages/Account/TabTeam.tsx @@ -3,12 +3,14 @@ import { useTheme } from "@mui/material"; import { useState, useMemo } from "react"; import { HeaderTeamControls } from "./components/HeaderTeamControls"; import { TeamTable } from "./components/TeamTable"; +import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog"; import { useGet } from "@/Hooks/UseApi"; import type { User, UserRole } from "@/Types/User"; export const TabTeam = () => { const theme = useTheme(); const [filter, setFilter] = useState(""); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const { data: users } = useGet("/auth/users"); @@ -19,13 +21,21 @@ export const TabTeam = () => { return users.filter((u) => u.role.includes(filter)); }, [users, filter]); + const handleOpenInviteDialog = () => setInviteDialogOpen(true); + const handleCloseInviteDialog = () => setInviteDialogOpen(false); + return ( + ); }; diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx index 807a153e4..405e175c0 100644 --- a/client/src/Pages/Account/components/HeaderTeamControls.tsx +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -13,11 +13,13 @@ import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; interface HeaderTeamControlsProps { filter: UserRole | ""; onFilterChange: (value: UserRole | "") => void; + onInviteClick?: () => void; } export const HeaderTeamControls = ({ filter, onFilterChange, + onInviteClick, }: HeaderTeamControlsProps) => { const theme = useTheme(); const { t } = useTranslation(); @@ -56,6 +58,7 @@ export const HeaderTeamControls = ({ variant="contained" color="primary" startIcon={} + onClick={onInviteClick} > {t("pages.account.team.inviteMember")} diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx new file mode 100644 index 000000000..8b4fee51f --- /dev/null +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -0,0 +1,72 @@ +import { Stack } from "@mui/material"; +import MenuItem from "@mui/material/MenuItem"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Dialog, TextField, Select, Button } from "@/Components/v2/inputs"; +import type { UserRole } from "@/Types/User"; + +interface InviteTeamMemberDialogProps { + open: boolean; + onClose: () => void; +} + +export const InviteTeamMemberDialog = ({ + open, + onClose, +}: InviteTeamMemberDialogProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const roleOptions: { value: UserRole; label: string }[] = [ + { value: "admin", label: t("common.auth.roles.admin") }, + { value: "user", label: t("common.auth.roles.user") }, + ]; + + return ( + {}} + maxWidth="sm" + fullWidth + confirmText={t("pages.account.team.invite.sendInvite")} + > + + + + + + + ); +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 45b329ea0..5aba9ab6d 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -572,6 +572,20 @@ "email": "Email", "role": "Role" } + }, + "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" + }, + "sendInvite": "Send invite", + "generateToken": "Generate token" } } }, From 1552018796bcc08b3071f29bcb32f5110a97772b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 8 Feb 2026 16:30:51 +0000 Subject: [PATCH 15/21] optional additionalButtons --- client/src/Components/v2/inputs/Dialog.tsx | 3 +++ .../components/InviteTeamMemberDialog.tsx | 17 +++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) 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 && ( + } > ))} - ); From d69cec9730d3817dd850001fac01921ef042d3cd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 17:52:14 +0000 Subject: [PATCH 16/21] dialog --- client/src/Hooks/useInviteForm.ts | 14 ++ .../Account/components/HeaderTeamControls.tsx | 4 +- .../components/InviteTeamMemberDialog.tsx | 143 ++++++++++++++---- client/src/Validation/invite.ts | 9 ++ client/src/locales/en.json | 16 +- 5 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 client/src/Hooks/useInviteForm.ts create mode 100644 client/src/Validation/invite.ts diff --git a/client/src/Hooks/useInviteForm.ts b/client/src/Hooks/useInviteForm.ts new file mode 100644 index 000000000..664cc96ab --- /dev/null +++ b/client/src/Hooks/useInviteForm.ts @@ -0,0 +1,14 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { inviteSchema, type InviteFormData } from "@/Validation/invite"; + +export const useInviteForm = () => { + const defaults: InviteFormData = { + email: "", + role: ["user"], + }; + + return { + resolver: zodResolver(inviteSchema), + defaults, + }; +}; diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx index 405e175c0..328deb411 100644 --- a/client/src/Pages/Account/components/HeaderTeamControls.tsx +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -60,7 +60,7 @@ export const HeaderTeamControls = ({ startIcon={} onClick={onInviteClick} > - {t("pages.account.team.inviteMember")} + {t("common.buttons.inviteMember")} )} {isSuperAdmin && ( @@ -69,7 +69,7 @@ export const HeaderTeamControls = ({ color="primary" startIcon={} > - {t("pages.account.team.addMember")} + {t("common.buttons.addMember")} )} diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx index 5029a5800..24f1f58f9 100644 --- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -1,9 +1,20 @@ import { Stack } from "@mui/material"; import MenuItem from "@mui/material/MenuItem"; -import { useTheme } from "@mui/material"; +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; @@ -16,29 +27,70 @@ export const InviteTeamMemberDialog = ({ }: 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 ( {}} + onCancel={handleClose} + onConfirm={handleSubmit(handleSendInvite)} + confirmText={t("common.buttons.sendInvite")} + loading={sendLoading || generateLoading} maxWidth="sm" fullWidth - confirmText={t("pages.account.team.invite.sendInvite")} additionalButtons={ } > @@ -46,27 +98,66 @@ export const InviteTeamMemberDialog = ({ gap={theme.spacing(4)} mt={theme.spacing(4)} > - ( + + )} /> - + ( + <> + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + {inviteLink && ( + <> + + {t("pages.account.team.invite.linkLabel")} + + + + )} ); 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/locales/en.json b/client/src/locales/en.json index 5aba9ab6d..36b89762c 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." @@ -195,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", @@ -299,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.", @@ -459,6 +465,7 @@ "discussions": "Discussions", "docs": "Docs", "incidents": "Incidents", + "inviteMember": "Invite member", "infrastructure": "Infrastructure", "logOut": "Log out", "logs": "Logs", @@ -559,8 +566,6 @@ } }, "team": { - "addMember": "Add member", - "inviteMember": "Invite member", "filter": { "placeholder": "Filter by role", "all": "All roles", @@ -584,8 +589,7 @@ "label": "Role", "placeholder": "Select a role" }, - "sendInvite": "Send invite", - "generateToken": "Generate token" + "linkLabel": "Invite link" } } }, @@ -675,8 +679,10 @@ "actions": { "configure": "Configure", "delete": "Delete", + "generateToken": "Generate token", "details": "Details", "incidents": "Incidents", + "inviteMember": "Invite member", "openSite": "Open site", "pause": "Pause", "resume": "Resume" @@ -1341,6 +1347,7 @@ "teamMember": "Team member" }, "save": "Save", + "sendInvite": "Send invite", "selectAll": "Select all", "settingsFailedToClearStats": "Failed to clear stats", "settingsFailedToDeleteMonitors": "Failed to delete all monitors", @@ -1467,6 +1474,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", From fafd581f06a67b191205f8fd7a33215f0b82cefc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 18:14:06 +0000 Subject: [PATCH 17/21] add team member initial commit --- client/src/Pages/Account/TabTeam.tsx | 10 +++ .../components/AddTeamMemberDialog.tsx | 87 +++++++++++++++++++ .../Account/components/HeaderTeamControls.tsx | 3 + client/src/locales/en.json | 4 + 4 files changed, 104 insertions(+) create mode 100644 client/src/Pages/Account/components/AddTeamMemberDialog.tsx diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx index 308becb2a..731f4eabf 100644 --- a/client/src/Pages/Account/TabTeam.tsx +++ b/client/src/Pages/Account/TabTeam.tsx @@ -4,6 +4,7 @@ import { useState, useMemo } from "react"; import { HeaderTeamControls } from "./components/HeaderTeamControls"; import { TeamTable } from "./components/TeamTable"; import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog"; +import { AddTeamMemberDialog } from "./components/AddTeamMemberDialog"; import { useGet } from "@/Hooks/UseApi"; import type { User, UserRole } from "@/Types/User"; @@ -11,6 +12,7 @@ export const TabTeam = () => { const theme = useTheme(); const [filter, setFilter] = useState(""); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); const { data: users } = useGet("/auth/users"); @@ -24,18 +26,26 @@ export const TabTeam = () => { const handleOpenInviteDialog = () => setInviteDialogOpen(true); const handleCloseInviteDialog = () => setInviteDialogOpen(false); + const handleOpenAddMemberDialog = () => setAddMemberDialogOpen(true); + const handleCloseAddMemberDialog = () => setAddMemberDialogOpen(false); + return ( + ); }; diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx new file mode 100644 index 000000000..1ca086684 --- /dev/null +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -0,0 +1,87 @@ +import { Stack } from "@mui/material"; +import MenuItem from "@mui/material/MenuItem"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Dialog, TextField, Select } from "@/Components/v2/inputs"; +import type { UserRole } from "@/Types/User"; + +interface AddTeamMemberDialogProps { + open: boolean; + onClose: () => void; +} + +export const AddTeamMemberDialog = ({ open, onClose }: AddTeamMemberDialogProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const roleOptions: { value: UserRole; label: string }[] = [ + { value: "admin", label: t("common.auth.roles.admin") }, + { value: "user", label: t("common.auth.roles.user") }, + ]; + + const handleClose = () => { + onClose(); + }; + + return ( + {}} + confirmText={t("common.buttons.addMember")} + maxWidth="sm" + fullWidth + > + + + + + + + + + + ); +}; diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx index 328deb411..57f000170 100644 --- a/client/src/Pages/Account/components/HeaderTeamControls.tsx +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -14,12 +14,14 @@ 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(); @@ -68,6 +70,7 @@ export const HeaderTeamControls = ({ variant="contained" color="primary" startIcon={} + onClick={onAddMemberClick} > {t("common.buttons.addMember")} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 36b89762c..06e923565 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -578,6 +578,10 @@ "role": "Role" } }, + "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", From 264e6f7d1ff074bbef7e798c0115f8d5daa8c51f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 18:29:36 +0000 Subject: [PATCH 18/21] form submission --- client/src/Hooks/useAddTeamMemberForm.ts | 20 ++ client/src/Pages/Account/TabTeam.tsx | 7 +- .../components/AddTeamMemberDialog.tsx | 176 ++++++++++++++---- client/src/Validation/addTeamMember.ts | 9 + 4 files changed, 171 insertions(+), 41 deletions(-) create mode 100644 client/src/Hooks/useAddTeamMemberForm.ts create mode 100644 client/src/Validation/addTeamMember.ts diff --git a/client/src/Hooks/useAddTeamMemberForm.ts b/client/src/Hooks/useAddTeamMemberForm.ts new file mode 100644 index 000000000..64cb0d353 --- /dev/null +++ b/client/src/Hooks/useAddTeamMemberForm.ts @@ -0,0 +1,20 @@ +import { + addTeamMemberSchema, + type AddTeamMemberFormData, +} from "@/Validation/addTeamMember"; + +export const useAddTeamMemberForm = () => { + const defaults: AddTeamMemberFormData = { + firstName: "", + lastName: "", + email: "", + password: "", + confirm: "", + role: ["user"], + }; + + return { + schema: addTeamMemberSchema, + defaults, + }; +}; diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx index 731f4eabf..4eb2f8a97 100644 --- a/client/src/Pages/Account/TabTeam.tsx +++ b/client/src/Pages/Account/TabTeam.tsx @@ -14,7 +14,7 @@ export const TabTeam = () => { const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); - const { data: users } = useGet("/auth/users"); + const { data: users, refetch } = useGet("/auth/users"); const filteredUsers = useMemo(() => { if (!users) return []; @@ -29,6 +29,10 @@ export const TabTeam = () => { const handleOpenAddMemberDialog = () => setAddMemberDialogOpen(true); const handleCloseAddMemberDialog = () => setAddMemberDialogOpen(false); + const handleRefetch = () => { + refetch(); + }; + return ( { ); diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index 1ca086684..aed713b1f 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -2,17 +2,39 @@ 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 type { UserRole } from "@/Types/User"; +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; } -export const AddTeamMemberDialog = ({ open, onClose }: AddTeamMemberDialogProps) => { +interface RegisterPayload { + user: Omit; +} + +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") }, @@ -20,17 +42,38 @@ export const AddTeamMemberDialog = ({ open, onClose }: AddTeamMemberDialogProps) ]; const handleClose = () => { + reset(); onClose(); }; + const onSubmit = async (data: AddTeamMemberFormData) => { + if (loading) return; + + const { confirm, ...userData } = data; + const payload: RegisterPayload = { user: userData }; + + const result = await post("/auth/register", payload); + + if (result?.success) { + reset(); + onClose(); + onSuccess?.(); + } else if (result?.msg) { + if (result.msg.toLowerCase().includes("email")) { + setError("email", { message: result.msg }); + } + } + }; + return ( {}} + onConfirm={handleSubmit(onSubmit)} confirmText={t("common.buttons.addMember")} + loading={loading} maxWidth="sm" fullWidth > @@ -38,48 +81,101 @@ export const AddTeamMemberDialog = ({ open, onClose }: AddTeamMemberDialogProps) gap={theme.spacing(4)} mt={theme.spacing(4)} > - ( + + )} /> - ( + + )} /> - ( + + )} /> - field.onChange([e.target.value])} + fieldLabel={t("pages.account.team.invite.role.label")} + placeholder={t("pages.account.team.invite.role.placeholder")} + fullWidth > - {option.label} - - ))} - - ( + + {option.label} + + ))} + + )} /> - ( + + )} + /> + ( + + )} /> 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; From 8b7fd826b3a428475b907d728cb5cb29ab613231 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 18:49:31 +0000 Subject: [PATCH 19/21] createUser endpoint --- .../components/AddTeamMemberDialog.tsx | 10 +++-- server/src/controllers/authController.ts | 24 ++++++++++++ server/src/controllers/inviteController.ts | 1 - server/src/routes/authRoute.ts | 1 + server/src/service/business/userService.ts | 38 +++++++++++++++++++ server/src/validation/joi.ts | 23 +++++++++++ 6 files changed, 93 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index aed713b1f..170af1724 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -17,7 +17,11 @@ interface AddTeamMemberDialogProps { } interface RegisterPayload { - user: Omit; + firstName: string; + lastName: string; + email: string; + password: string; + role: string[]; } export const AddTeamMemberDialog = ({ @@ -50,9 +54,9 @@ export const AddTeamMemberDialog = ({ if (loading) return; const { confirm, ...userData } = data; - const payload: RegisterPayload = { user: userData }; + const payload: RegisterPayload = userData; - const result = await post("/auth/register", payload); + const result = await post("/auth/users", payload); if (result?.success) { reset(); diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index d6fe6aeff..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) { 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/routes/authRoute.ts b/server/src/routes/authRoute.ts index 97e85d61f..cc1ec3fd6 100755 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -25,6 +25,7 @@ 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.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 39b05a737..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"; @@ -153,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); diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index f72addc5e..f01a8da40 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -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, }; From a46e282633a1f1a5d8258f933ded88997374be5c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 18:57:24 +0000 Subject: [PATCH 20/21] role column --- client/src/Pages/Account/components/TeamTable.tsx | 7 +++++++ client/src/locales/en.json | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Account/components/TeamTable.tsx b/client/src/Pages/Account/components/TeamTable.tsx index 5c2455bec..2583d1c39 100644 --- a/client/src/Pages/Account/components/TeamTable.tsx +++ b/client/src/Pages/Account/components/TeamTable.tsx @@ -35,6 +35,13 @@ export const TeamTable = ({ users }: TeamTableProps) => { ), }, + { + id: "created", + content: t("pages.account.team.table.headers.created"), + render: (row) => ( + {new Date(row.createdAt).toLocaleDateString()} + ), + }, ]; const handleRowClick = (row: User) => { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 06e923565..69684af6a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -575,7 +575,8 @@ "table": { "headers": { "email": "Email", - "role": "Role" + "role": "Role", + "created": "Created at" } }, "addMember": { From 08e4b0c39e75e06be3a0dfe9f8fd7376343919a0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 19:15:22 +0000 Subject: [PATCH 21/21] remove unused --- .../components/AddMemberMenu/index.jsx | 74 --- .../AddTeamMember/hooks/useAddTeamMember.jsx | 48 -- .../components/AddTeamMember/index.jsx | 213 --------- .../Account/components/PasswordPanel.jsx | 302 ------------ .../Pages/Account/components/ProfilePanel.jsx | 437 ------------------ .../Pages/Account/components/TeamPanel.jsx | 353 -------------- client/src/Pages/Account/old.jsx | 99 ---- 7 files changed, 1526 deletions(-) delete mode 100644 client/src/Pages/Account/components/AddMemberMenu/index.jsx delete mode 100644 client/src/Pages/Account/components/AddTeamMember/hooks/useAddTeamMember.jsx delete mode 100644 client/src/Pages/Account/components/AddTeamMember/index.jsx delete mode 100644 client/src/Pages/Account/components/PasswordPanel.jsx delete mode 100644 client/src/Pages/Account/components/ProfilePanel.jsx delete mode 100644 client/src/Pages/Account/components/TeamPanel.jsx delete mode 100644 client/src/Pages/Account/old.jsx diff --git a/client/src/Pages/Account/components/AddMemberMenu/index.jsx b/client/src/Pages/Account/components/AddMemberMenu/index.jsx deleted file mode 100644 index 96bea3522..000000000 --- a/client/src/Pages/Account/components/AddMemberMenu/index.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useState } from "react"; -import Button from "@mui/material/Button"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import { useTheme } from "@emotion/react"; -import Icon from "@/Components/v1/Icon"; -import { useTranslation } from "react-i18next"; -import Proptypes from "prop-types"; - -const AddMemberMenu = ({ handleInviteOpen, handleIsRegisterOpen }) => { - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - const { t } = useTranslation(); - const theme = useTheme(); - const handleClick = (event) => { - setAnchorEl(event.currentTarget); - }; - const handleClose = () => { - setAnchorEl(null); - }; - - return ( - <> - - - { - handleClose(); - handleInviteOpen(); - }} - > - {t("teamPanel.inviteTeamMember")} - - { - handleClose(); - handleIsRegisterOpen(true); - }} - > - {t("teamPanel.register")} - - - - ); -}; - -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 ( - <> - - - - - - - - - setToInvite((prev) => ({ - ...prev, - role: [event.target.value], - })) - } - items={[ - { _id: "admin", name: t("roles.admin") }, - { _id: "user", name: t("roles.teamMember") }, - ]} - /> - {token && {t("teamPanel.inviteLink")}} - {token && ( - - )} - - - - - - - - ); -}; - -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/old.jsx b/client/src/Pages/Account/old.jsx deleted file mode 100644 index 3bf480336..000000000 --- a/client/src/Pages/Account/old.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;