diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6ad368816..3f3b58b5e 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -464,6 +464,42 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/user-management/users", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + userIds: z.string().array() + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + username: true, + firstName: true, + lastName: true, + email: true, + id: true + }).array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const users = await server.services.superAdmin.deleteUsers(req.body.userIds); + + return { + users + }; + } + }); + server.route({ method: "PATCH", url: "/user-management/users/:userId/admin-access", diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index ff47718f1..93ac5cadb 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -704,10 +704,39 @@ export const superAdminServiceFactory = ({ }; const deleteUser = async (userId: string) => { + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.length === 1 && superAdmins[0].id === userId) { + throw new BadRequestError({ + message: "Cannot delete the only server admin on this instance. Add another server admin to delete this user." + }); + } + const user = await userDAL.deleteById(userId); return user; }; + const deleteUsers = async (userIds: string[]) => { + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.every((superAdmin) => userIds.includes(superAdmin.id))) { + throw new BadRequestError({ + message: "Instance must have at least one server admin. Add another server admin to delete these users." + }); + } + + const users = await userDAL.delete({ + $in: { + id: userIds + } + }); + return users; + }; + const deleteIdentitySuperAdminAccess = async (identityId: string, actorId: string) => { const identity = await identityDAL.findById(identityId); if (!identity) { @@ -730,6 +759,17 @@ export const superAdminServiceFactory = ({ throw new NotFoundError({ name: "User", message: "User not found" }); } + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.length === 1 && superAdmins[0].id === userId) { + throw new BadRequestError({ + message: + "Cannot remove the only server admin on this instance. Add another server admin to remove status for this user." + }); + } + const updatedUser = userDAL.updateById(userId, { superAdmin: false }); return updatedUser; @@ -913,6 +953,7 @@ export const superAdminServiceFactory = ({ initializeAdminIntegrationConfigSync, initializeEnvConfigSync, getEnvOverrides, - getEnvOverridesOrganized + getEnvOverridesOrganized, + deleteUsers }; }; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index 916067f00..b196f19e7 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -68,6 +68,25 @@ export const useAdminDeleteUser = () => { }); }; +export const useAdminBulkDeleteUsers = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (userIds: string[]) => { + await apiRequest.delete("/api/v1/admin/user-management/users", { + data: { userIds } + }); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getUsers] + }); + queryClient.invalidateQueries({ queryKey: adminStandaloneKeys.getOrganizations }); + } + }); +}; + export const useAdminDeleteOrganizationMembership = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx index 7d4527032..9c44b72c4 100644 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx +++ b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx @@ -1,5 +1,11 @@ import { useState } from "react"; -import { faEllipsis, faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; +import { + faEllipsisV, + faMagnifyingGlass, + faServer, + faShieldHalved, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -12,6 +18,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, EmptyState, + IconButton, Input, Table, TableContainer, @@ -88,18 +95,33 @@ const IdentityPanelTable = ({ {isInstanceAdmin && (
- -
- -
+ + + + - + {isInstanceAdmin && ( { e.stopPropagation(); handlePopUpOpen("removeServerAdmin", { name, id }); }} + icon={ +
+ + +
+ } > Remove Server Admin
@@ -125,7 +147,7 @@ const IdentityPanelTable = ({ isDisabled={isFetchingNextPage || !hasNextPage} onClick={() => fetchNextPage()} > - {hasNextPage ? "Load More" : "End of list"} + {hasNextPage ? "Load More" : "End of List"} )}
diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx b/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx index 500796797..1f2471aa2 100644 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx +++ b/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx @@ -1,13 +1,19 @@ -import { useState } from "react"; +import { Dispatch, SetStateAction, useState } from "react"; import { faCheckCircle, - faEllipsis, + faEllipsisV, faFilter, faMagnifyingGlass, + faShieldHalved, + faTrash, faUsers, - faUserShield + faUserShield, + faUserXmark, + faWarning, + faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { InfiniteData } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -15,6 +21,7 @@ import { createNotification } from "@app/components/notifications"; import { Badge, Button, + Checkbox, DeleteActionModal, DropdownMenu, DropdownMenuContent, @@ -31,23 +38,37 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; -import { useSubscription } from "@app/context"; +import { useSubscription, useUser } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; import { + useAdminBulkDeleteUsers, useAdminDeleteUser, useAdminGetUsers, useAdminGrantServerAdminAccess, useRemoveUserServerAdminAccess } from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions"; const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user"; const UserPanelTable = ({ - handlePopUpOpen + handlePopUpOpen, + users: usersPages, + isPending, + adminsOnly, + searchUserFilter, + setSearchUserFilter, + setAdminsOnly, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + selectedUsers, + setSelectedUsers }: { handlePopUpOpen: ( popUpName: keyof UsePopUpState< @@ -59,21 +80,38 @@ const UserPanelTable = ({ message?: string; } ) => void; + isPending: boolean; + users: InfiniteData | undefined; + adminsOnly: boolean; + setAdminsOnly: (adminsOnly: boolean) => void; + searchUserFilter: string; + setSearchUserFilter: (filter: string) => void; + selectedUsers: User[]; + setSelectedUsers: Dispatch>; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + hasNextPage: boolean; }) => { - const [searchUserFilter, setSearchUserFilter] = useState(""); - const [adminsOnly, setAdminsOnly] = useState(false); - const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); const { subscription } = useSubscription(); - const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ - limit: 20, - searchTerm: debouncedSearchTerm, - adminsOnly - }); + const users = usersPages?.pages.flat(); - const isEmpty = !isPending && !data?.pages?.[0].length; + const isEmpty = !isPending && !users?.length; const isTableFiltered = Boolean(adminsOnly); + const selectedUserIds = selectedUsers.map((user) => user.id); + + const isPageSelected = users?.length + ? users.every((user) => selectedUserIds.includes(user.id)) + : false; + + // eslint-disable-next-line no-nested-ternary + const isPageIndeterminate = isPageSelected + ? false + : users?.length + ? users?.some((user) => selectedUserIds.includes(user.id)) + : false; + return ( <>
@@ -121,90 +159,144 @@ const UserPanelTable = ({ + - - + {isPending && } {!isPending && - data?.pages?.map((users) => - users.map(({ username, email, firstName, lastName, id, superAdmin }) => { - const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; + users?.map((user) => { + const { username, email, firstName, lastName, id, superAdmin } = user; + const name = firstName || lastName ? `${firstName} ${lastName}` : null; - return ( - - + + - - + + - - ); - }) - )} + )} + {superAdmin && ( + + + + + } + onClick={(e) => { + e.stopPropagation(); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: removeServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("removeServerAdmin", { username, id }); + }} + > + Remove Server Admin + + )} + + + + + + ); + })}
+ { + if (isPageSelected) { + setSelectedUsers((prev) => + prev.filter((u) => !users?.find((user) => user.id === u.id)) + ); + } else { + setSelectedUsers((prev) => [ + ...prev, + ...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? []) + ]); + } + }} + /> + NameUsername + Username
- {name} + const isSelected = selectedUserIds.includes(id); + return ( +
+ { + e.stopPropagation(); + setSelectedUsers((prev) => + isSelected ? prev.filter((u) => u.id !== id) : [...prev, user] + ); + }} + /> + +
+

+ {name ?? Not Set} +

{superAdmin && ( - + Server Admin )} -
{email} -
- - -
- -
-
- +
+
+

{email}

+
+
+ + + + + + + + { + e.stopPropagation(); + handlePopUpOpen("removeUser", { username, id }); + }} + icon={} + > + Remove User + + {!superAdmin && ( } onClick={(e) => { e.stopPropagation(); - handlePopUpOpen("removeUser", { username, id }); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: addServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("upgradeToServerAdmin", { username, id }); }} > - Remove User + Make User Server Admin - {!superAdmin && ( - { - e.stopPropagation(); - if (!subscription?.instanceUserManagement) { - handlePopUpOpen("upgradePlan", { - username, - id, - message: addServerAdminUpgradePlanMessage - }); - return; - } - handlePopUpOpen("upgradeToServerAdmin", { username, id }); - }} - > - Make User Server Admin - - )} - {superAdmin && ( - { - e.stopPropagation(); - if (!subscription?.instanceUserManagement) { - handlePopUpOpen("upgradePlan", { - username, - id, - message: removeServerAdminUpgradePlanMessage - }); - return; - } - handlePopUpOpen("removeServerAdmin", { username, id }); - }} - > - Remove Server Admin - - )} - - -
-
{!isPending && isEmpty && } @@ -218,7 +310,7 @@ const UserPanelTable = ({ isDisabled={isFetchingNextPage || !hasNextPage} onClick={() => fetchNextPage()} > - {hasNextPage ? "Load More" : "End of list"} + {hasNextPage ? "Load More" : "End of List"} )}
@@ -231,13 +323,36 @@ export const UserIdentitiesTable = () => { "removeUser", "upgradePlan", "upgradeToServerAdmin", - "removeServerAdmin" + "removeServerAdmin", + "removeUsers" ] as const); + const { + user: { id: userId } + } = useUser(); + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers(); const { mutateAsync: grantAdminAccess } = useAdminGrantServerAdminAccess(); const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess(); + const [selectedUsers, setSelectedUsers] = useState([]); + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [adminsOnly, setAdminsOnly] = useState(false); + const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { + data: users, + isPending, + isFetchingNextPage, + hasNextPage, + fetchNextPage + } = useAdminGetUsers({ + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly + }); + const handleRemoveUser = async () => { const { id } = popUp?.removeUser?.data as { id: string; username: string }; @@ -295,45 +410,158 @@ export const UserIdentitiesTable = () => { handlePopUpClose("removeServerAdmin"); }; + const handleRemoveUsers = async () => { + try { + await deleteUsers(selectedUsers.map((user) => user.id)); + + createNotification({ + text: "Successfully removed users", + type: "success" + }); + + setSelectedUsers([]); + handlePopUpClose("removeUsers"); + } catch { + createNotification({ + text: "Failed to remove users", + type: "error" + }); + } + }; + return ( -
- - handlePopUpToggle("removeUser", isOpen)} - onDeleteApproved={handleRemoveUser} - /> - handlePopUpToggle("upgradeToServerAdmin", isOpen)} - deleteKey="confirm" - onDeleteApproved={handleGrantServerAdminAccess} - buttonText="Grant Access" - /> - handlePopUpToggle("removeServerAdmin", isOpen)} - deleteKey="confirm" - onDeleteApproved={handleRemoveServerAdminAccess} - buttonText="Remove Access" - /> - handlePopUpToggle("upgradePlan", isOpen)} - text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} - /> -
+ <> +
0 && "h-16" + )} + > +
+
{selectedUsers.length} Selected
+ + +
+
+
+ + handlePopUpToggle("removeUser", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("upgradeToServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleGrantServerAdminAccess} + buttonText="Grant Access" + /> + handlePopUpToggle("removeServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveServerAdminAccess} + buttonText="Remove Access" + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} + /> + handlePopUpToggle("removeUsers", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => handleRemoveUsers()} + buttonText="Remove" + > +
+ The following members will be removed: +
+
+
    + {selectedUsers?.map((user) => { + const email = user.email ?? user.username; + return ( +
  • +
    +

    + {user.firstName || user.lastName ? ( + <> + {`${`${user.firstName} ${user.lastName}`.trim()} `}( + {email}) + + ) : ( + {email} + )}{" "} +

    + {userId === user.id && ( + +
    + + + Removing Yourself + +
    +
    + )} +
    +
  • + ); + })} +
+
+
+
+ ); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 342b3ca62..679e9f664 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -309,13 +309,16 @@ export const OrgMembersTable = ({ const filteredMembersPage = filteredMembers.slice(offset, perPage * page); - const isPageSelected = filteredMembersPage.every((member) => - selectedMemberIds.includes(member.id) - ); + const isPageSelected = filteredMembersPage.length + ? filteredMembersPage.every((member) => selectedMemberIds.includes(member.id)) + : false; + // eslint-disable-next-line no-nested-ternary const isPageIndeterminate = isPageSelected ? false - : filteredMembersPage.some((member) => selectedMemberIds.includes(member.id)); + : filteredMembersPage.length + ? filteredMembersPage.some((member) => selectedMemberIds.includes(member.id)) + : false; return (