From 82998a7795b15f4ecf0aba2355f440edd0f5a3e3 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 6 Mar 2026 14:18:27 -0500 Subject: [PATCH 1/4] feat: add ability to remove team members ## Changes - Add DELETE /api/v1/auth/users/:userId endpoint (admin + superadmin) - Add deleteUserById service method with role hierarchy enforcement - Superadmins can remove admins and regular users - Admins can only remove regular users - No one can remove superadmins, demo users, or themselves via this endpoint - Add "Remove user" button with confirmation dialog on edit user page - Allow admins to view user details and access the edit user page - Add i18n strings for remove user button and confirmation dialog ## Access control - superadmin -> can remove admin, user - admin -> can remove user only - admin cannot remove superadmin or other admins (enforced on both backend and frontend) --- client/src/Pages/Account/EditUser/index.tsx | 74 ++++++++++++++++--- .../Pages/Account/components/TeamTable.tsx | 8 +- client/src/locales/en.json | 9 +++ server/src/controllers/authController.ts | 14 ++++ server/src/routes/authRoute.ts | 3 +- server/src/service/business/userService.ts | 45 ++++++++++- 6 files changed, 136 insertions(+), 17 deletions(-) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index a3ddebaba..482a2f7bb 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -14,9 +14,14 @@ import type { UserRole, User } from "@/Types/User"; import { useParams } from "react-router-dom"; import { useTheme } from "@mui/material"; import { useEditUserForm } from "@/Hooks/useEditUserForm"; -import { useGet, usePatch } from "@/Hooks/UseApi"; +import { useGet, usePatch, useDelete } from "@/Hooks/UseApi"; import type { EditUserFormData } from "@/Validation/editUser"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { DialogInput } from "@/Components/inputs/Dialog"; +import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; interface RoleOption { id: UserRole; @@ -29,6 +34,12 @@ const EditUserPage = () => { const { userId } = useParams<{ userId: string }>(); const { resolver, defaults } = useEditUserForm(); const { patch, loading: isSaving } = usePatch(); + const { deleteFn, loading: isDeleting } = useDelete(); + const navigate = useNavigate(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const isAdmin = useIsAdmin(); + const isSuperAdmin = useIsSuperAdmin(); + const currentUser = useSelector((state: RootState) => state.auth.user); const { data: user, isLoading } = useGet(`/auth/users/${userId}`); @@ -60,6 +71,21 @@ const EditUserPage = () => { setValue("role", newRoles, { shouldValidate: true }); }; + const canDeleteUser = + isAdmin && + userId !== currentUser?.id && + !user?.role?.includes("superadmin") && + !user?.role?.includes("demo") && + (isSuperAdmin || user?.role?.every((r) => r !== "admin" && r !== "superadmin")); + + const handleDeleteUser = async () => { + const result = await deleteFn(`/auth/users/${userId}`); + if (result) { + setShowDeleteDialog(false); + navigate("/account", { state: { tab: "team" } }); + } + }; + const onSubmit = async (data: EditUserFormData) => { await patch(`/auth/users/${userId}`, data); }; @@ -165,17 +191,45 @@ const EditUserPage = () => { } /> - + {canDeleteUser && ( + + )} + + + setShowDeleteDialog(false)} + onConfirm={handleDeleteUser} + confirmColor="error" + confirmText={t("pages.editUser.buttons.removeUser")} + loading={isDeleting} + /> ); }; diff --git a/client/src/Pages/Account/components/TeamTable.tsx b/client/src/Pages/Account/components/TeamTable.tsx index ab8dcc3b4..289bfb331 100644 --- a/client/src/Pages/Account/components/TeamTable.tsx +++ b/client/src/Pages/Account/components/TeamTable.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Table } from "@/Components/design-elements"; import type { Header } from "@/Components/design-elements/Table"; -import { useIsSuperAdmin } from "@/Hooks/useIsAdmin"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; import type { User } from "@/Types/User"; interface TeamTableProps { @@ -13,7 +13,7 @@ interface TeamTableProps { export const TeamTable = ({ users }: TeamTableProps) => { const { t } = useTranslation(); const navigate = useNavigate(); - const isSuperAdmin = useIsSuperAdmin(); + const isAdmin = useIsAdmin(); const headers: Header[] = [ { @@ -45,7 +45,7 @@ export const TeamTable = ({ users }: TeamTableProps) => { ]; const handleRowClick = (row: User) => { - if (isSuperAdmin) { + if (isAdmin) { navigate(`/account/team/${row.id}`); } }; @@ -54,7 +54,7 @@ export const TeamTable = ({ users }: TeamTableProps) => { ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c6bf41478..a6d5a8d41 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -619,6 +619,15 @@ "title": "Roles", "description": "Assign roles to the user. Multiple roles can be selected." } + }, + "buttons": { + "removeUser": "Remove user" + }, + "dialog": { + "removeUser": { + "title": "Remove user", + "content": "Are you sure you want to remove {{name}} from your team? This action cannot be undone." + } } }, "incidents": { diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 8add39087..f196e4dd5 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -180,6 +180,20 @@ class AuthController { } }; + deleteUserById = async (req: Request, res: Response, next: NextFunction) => { + try { + getUserByIdParamValidation.parse(req.params); + const targetUserId = req.params.userId; + await this.userService.deleteUserById(req.user, targetUserId); + return res.status(200).json({ + success: true, + msg: "User removed successfully", + }); + } catch (error) { + next(error); + } + }; + getAllUsers = async (req: Request, res: Response, next: NextFunction) => { try { const allUsers = await this.userService.getAllUsers(); diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts index cc1ec3fd6..2e5fa1069 100755 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -26,9 +26,10 @@ class AuthRoutes { 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.get("/users/:userId", verifyJWT, isAllowed(["admin", "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); + this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById); 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 7cd1491c3..48f3a51cf 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -29,6 +29,7 @@ export interface IUserService { validateRecovery(recoveryToken: string): Promise; resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>; deleteUser(user: User): Promise; + deleteUserById(actor: User, targetUserId: string): Promise; getAllUsers(): Promise; getUserById(roles: UserRole[], userId: string): Promise; editUserById(userId: string, patch: Partial): Promise; @@ -357,13 +358,53 @@ export class UserService implements IUserService { await this.usersRepository.deleteById(userId); }; + deleteUserById = async (actor: User, targetUserId: string) => { + if (actor.id === targetUserId) { + throw new AppError({ message: "Cannot delete your own account from here", service: SERVICE_NAME, method: "deleteUserById", status: 400 }); + } + + const targetUser = await this.usersRepository.findById(targetUserId); + + if (targetUser.teamId !== actor.teamId) { + throw new AppError({ message: "User is not on your team", service: SERVICE_NAME, method: "deleteUserById", status: 403 }); + } + + if (targetUser.role.includes("demo")) { + throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUserById", status: 400 }); + } + + const actorRoles = actor.role; + const targetRoles = targetUser.role; + + // Check actor can manage all of target's roles + 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 remove this user", + service: SERVICE_NAME, + method: "deleteUserById", + status: 403, + }); + } + } + + await this.usersRepository.deleteById(targetUserId); + + this.logger.info({ + message: `User ${targetUserId} deleted by ${actor.id}`, + service: SERVICE_NAME, + method: "deleteUserById", + }); + }; + getAllUsers = async () => { return await this.usersRepository.findAll(); }; getUserById = async (roles: any, userId: any) => { - if (!roles.includes("superadmin")) { - throw new AppError({ message: "User is not a superadmin", service: SERVICE_NAME, status: 403 }); + if (!roles.includes("superadmin") && !roles.includes("admin")) { + throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 }); } const user = await this.usersRepository.findById(userId); From 7c953978edbb58782e594c25a72110aff8d71a88 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 6 Mar 2026 14:31:59 -0500 Subject: [PATCH 2/4] refactor: simplify EditUser imports and canDeleteUser logic - Merge duplicate react-router-dom imports into one - Replace useIsAdmin + useIsSuperAdmin + useSelector with single useSelector - Remove redundant !includes("superadmin") check from canDeleteUser --- client/src/Pages/Account/EditUser/index.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index 482a2f7bb..d31722dcf 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -11,15 +11,13 @@ import { ConfigBox, BasePage } from "@/Components/design-elements"; import { UserRoles } from "@/Types/User"; import { useTranslation } from "react-i18next"; import type { UserRole, User } from "@/Types/User"; -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { useTheme } from "@mui/material"; import { useEditUserForm } from "@/Hooks/useEditUserForm"; import { useGet, usePatch, useDelete } from "@/Hooks/UseApi"; import type { EditUserFormData } from "@/Validation/editUser"; import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { DialogInput } from "@/Components/inputs/Dialog"; -import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; @@ -37,9 +35,9 @@ const EditUserPage = () => { const { deleteFn, loading: isDeleting } = useDelete(); const navigate = useNavigate(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const isAdmin = useIsAdmin(); - const isSuperAdmin = useIsSuperAdmin(); const currentUser = useSelector((state: RootState) => state.auth.user); + const isSuperAdmin = currentUser?.role?.includes("superadmin") ?? false; + const isAdmin = isSuperAdmin || (currentUser?.role?.includes("admin") ?? false); const { data: user, isLoading } = useGet(`/auth/users/${userId}`); @@ -74,7 +72,6 @@ const EditUserPage = () => { const canDeleteUser = isAdmin && userId !== currentUser?.id && - !user?.role?.includes("superadmin") && !user?.role?.includes("demo") && (isSuperAdmin || user?.role?.every((r) => r !== "admin" && r !== "superadmin")); From 172467c0a6a1e100b0346cc8adfc61695373bcdd Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 6 Mar 2026 15:19:01 -0500 Subject: [PATCH 3/4] fix: address PR review feedback - Use useIsAdmin and useIsSuperAdmin hooks instead of manual role checks - Close delete dialog on failure, not just on success --- client/src/Pages/Account/EditUser/index.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index d31722dcf..728f9f0e6 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -20,6 +20,7 @@ import { useEffect, useState } from "react"; import { DialogInput } from "@/Components/inputs/Dialog"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; +import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; interface RoleOption { id: UserRole; @@ -36,8 +37,8 @@ const EditUserPage = () => { const navigate = useNavigate(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const currentUser = useSelector((state: RootState) => state.auth.user); - const isSuperAdmin = currentUser?.role?.includes("superadmin") ?? false; - const isAdmin = isSuperAdmin || (currentUser?.role?.includes("admin") ?? false); + const isSuperAdmin = useIsSuperAdmin(); + const isAdmin = useIsAdmin(); const { data: user, isLoading } = useGet(`/auth/users/${userId}`); @@ -77,8 +78,8 @@ const EditUserPage = () => { const handleDeleteUser = async () => { const result = await deleteFn(`/auth/users/${userId}`); + setShowDeleteDialog(false); if (result) { - setShowDeleteDialog(false); navigate("/account", { state: { tab: "team" } }); } }; From d7d237d37b64af677a1683919b8481777522c60f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Mar 2026 12:35:38 -0800 Subject: [PATCH 4/4] move button strings to correct location, flex-end --- client/src/Pages/Account/EditUser/index.tsx | 9 +++++---- client/src/locales/en.json | 6 ++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index d31722dcf..79068fac2 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -20,6 +20,7 @@ import { useEffect, useState } from "react"; import { DialogInput } from "@/Components/inputs/Dialog"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; interface RoleOption { id: UserRole; @@ -189,8 +190,9 @@ const EditUserPage = () => { } /> {canDeleteUser && ( @@ -200,7 +202,7 @@ const EditUserPage = () => { onClick={() => setShowDeleteDialog(true)} sx={{ minWidth: 100 }} > - {t("pages.editUser.buttons.removeUser")} + {t("common.buttons.removeUser")} )}