Merge pull request #4291 from Infisical/server-admin-bulk-delete

improvement(server-admin): add bulk delete users support, bulk actions server admin table support, overflow/truncation and dropdown improvements
This commit is contained in:
Scott Wilson
2025-07-31 17:19:03 -07:00
committed by GitHub
6 changed files with 483 additions and 134 deletions

View File

@@ -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",

View File

@@ -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
};
};

View File

@@ -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<object, object, { organizationId: string; membershipId: string }>({

View File

@@ -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 && (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent sideOffset={2} align="end">
{isInstanceAdmin && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeServerAdmin", { name, id });
}}
icon={
<div className="relative">
<FontAwesomeIcon icon={faShieldHalved} />
<FontAwesomeIcon
className="absolute -bottom-[0.01rem] -right-1"
size="2xs"
icon={faXmark}
/>
</div>
}
>
Remove Server Admin
</DropdownMenuItem>
@@ -125,7 +147,7 @@ const IdentityPanelTable = ({
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
{hasNextPage ? "Load More" : "End of List"}
</Button>
)}
</div>

View File

@@ -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<User[], unknown> | undefined;
adminsOnly: boolean;
setAdminsOnly: (adminsOnly: boolean) => void;
searchUserFilter: string;
setSearchUserFilter: (filter: string) => void;
selectedUsers: User[];
setSelectedUsers: Dispatch<SetStateAction<User[]>>;
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 (
<>
<div className="flex gap-2">
@@ -121,90 +159,144 @@ const UserPanelTable = ({
<Table>
<THead>
<Tr>
<Th className="w-5">
<Checkbox
id="member-page-select"
isChecked={isPageSelected || isPageIndeterminate}
isIndeterminate={isPageIndeterminate}
onCheckedChange={() => {
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)) ?? [])
]);
}
}}
/>
</Th>
<Th className="w-5/12">Name</Th>
<Th className="w-5/12">Username</Th>
<Th className="w-5" />
<Th className="w-1/2">Username</Th>
<Th className="w-2/12" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="users" />}
{!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 (
<Tr key={`user-${id}`} className="w-full">
<Td className="w-5/12">
{name}
const isSelected = selectedUserIds.includes(id);
return (
<Tr key={`user-${id}`} className="w-full">
<Td>
<Checkbox
id={`select-user-${id}`}
isChecked={isSelected}
onClick={(e) => {
e.stopPropagation();
setSelectedUsers((prev) =>
isSelected ? prev.filter((u) => u.id !== id) : [...prev, user]
);
}}
/>
</Td>
<Td className="w-5/12 max-w-0">
<div className="flex items-center">
<p className="truncate">
{name ?? <span className="text-mineshaft-400">Not Set</span>}
</p>
{superAdmin && (
<Badge variant="primary" className="ml-2">
<Badge variant="primary" className="ml-2 whitespace-nowrap">
Server Admin
</Badge>
)}
</Td>
<Td className="w-5/12">{email}</Td>
<Td>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
</div>
</Td>
<Td className="w-5/12 max-w-0">
<p className="truncate">{email}</p>
</Td>
<Td>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeUser", { username, id });
}}
icon={<FontAwesomeIcon icon={faUserXmark} />}
>
Remove User
</DropdownMenuItem>
{!superAdmin && (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserShield} />}
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
</DropdownMenuItem>
{!superAdmin && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (!subscription?.instanceUserManagement) {
handlePopUpOpen("upgradePlan", {
username,
id,
message: addServerAdminUpgradePlanMessage
});
return;
}
handlePopUpOpen("upgradeToServerAdmin", { username, id });
}}
>
Make User Server Admin
</DropdownMenuItem>
)}
{superAdmin && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (!subscription?.instanceUserManagement) {
handlePopUpOpen("upgradePlan", {
username,
id,
message: removeServerAdminUpgradePlanMessage
});
return;
}
handlePopUpOpen("removeServerAdmin", { username, id });
}}
>
Remove Server Admin
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})
)}
)}
{superAdmin && (
<DropdownMenuItem
icon={
<div className="relative">
<FontAwesomeIcon icon={faShieldHalved} />
<FontAwesomeIcon
className="absolute -bottom-[0.01rem] -right-1"
size="2xs"
icon={faXmark}
/>
</div>
}
onClick={(e) => {
e.stopPropagation();
if (!subscription?.instanceUserManagement) {
handlePopUpOpen("upgradePlan", {
username,
id,
message: removeServerAdminUpgradePlanMessage
});
return;
}
handlePopUpOpen("removeServerAdmin", { username, id });
}}
>
Remove Server Admin
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
@@ -218,7 +310,7 @@ const UserPanelTable = ({
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
{hasNextPage ? "Load More" : "End of List"}
</Button>
)}
</div>
@@ -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<User[]>([]);
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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<UserPanelTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.removeUser.isOpen}
deleteKey="remove"
title={`Are you sure you want to delete User with username ${
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<DeleteActionModal
isOpen={popUp.upgradeToServerAdmin.isOpen}
title={`Are you sure you want to grant Server Admin permissions to ${
(popUp?.upgradeToServerAdmin?.data as { id: string; username: string })?.username || ""
}?`}
subTitle=""
onChange={(isOpen) => handlePopUpToggle("upgradeToServerAdmin", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleGrantServerAdminAccess}
buttonText="Grant Access"
/>
<DeleteActionModal
isOpen={popUp.removeServerAdmin.isOpen}
title={`Are you sure you want to remove Server Admin permissions from ${
(popUp?.removeServerAdmin?.data as { id: string; username: string })?.username || ""
}?`}
subTitle=""
onChange={(isOpen) => handlePopUpToggle("removeServerAdmin", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleRemoveServerAdminAccess}
buttonText="Remove Access"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
/>
</div>
<>
<div
className={twMerge(
"h-0 flex-shrink-0 overflow-hidden transition-all",
selectedUsers.length > 0 && "h-16"
)}
>
<div className="flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
<div className="mr-2 text-sm">{selectedUsers.length} Selected</div>
<button
type="button"
className="mr-auto text-xs text-mineshaft-400 underline-offset-2 hover:text-mineshaft-200 hover:underline"
onClick={() => setSelectedUsers([])}
>
Unselect All
</button>
<Button
variant="outline_bg"
colorSchema="danger"
leftIcon={<FontAwesomeIcon icon={faTrash} />}
className="ml-2"
onClick={() => {
if (!selectedUsers?.length) return;
handlePopUpOpen("removeUsers");
}}
size="xs"
>
Delete
</Button>
</div>
</div>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<UserPanelTable
handlePopUpOpen={handlePopUpOpen}
users={users}
selectedUsers={selectedUsers}
setSelectedUsers={setSelectedUsers}
searchUserFilter={searchUserFilter}
setSearchUserFilter={setSearchUserFilter}
isPending={isPending}
adminsOnly={adminsOnly}
setAdminsOnly={setAdminsOnly}
isFetchingNextPage={isFetchingNextPage}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
/>
<DeleteActionModal
isOpen={popUp.removeUser.isOpen}
deleteKey="remove"
title={`Are you sure you want to delete User with username ${
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<DeleteActionModal
isOpen={popUp.upgradeToServerAdmin.isOpen}
title={`Are you sure you want to grant Server Admin permissions to ${
(popUp?.upgradeToServerAdmin?.data as { id: string; username: string })?.username || ""
}?`}
subTitle=""
onChange={(isOpen) => handlePopUpToggle("upgradeToServerAdmin", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleGrantServerAdminAccess}
buttonText="Grant Access"
/>
<DeleteActionModal
isOpen={popUp.removeServerAdmin.isOpen}
title={`Are you sure you want to remove Server Admin permissions from ${
(popUp?.removeServerAdmin?.data as { id: string; username: string })?.username || ""
}?`}
subTitle=""
onChange={(isOpen) => handlePopUpToggle("removeServerAdmin", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleRemoveServerAdminAccess}
buttonText="Remove Access"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
/>
<DeleteActionModal
isOpen={popUp.removeUsers.isOpen}
title="Are you sure you want to remove the following users?"
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => handleRemoveUsers()}
buttonText="Remove"
>
<div className="mt-4 text-sm text-mineshaft-400">
The following members will be removed:
</div>
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
<ul className="list-disc">
{selectedUsers?.map((user) => {
const email = user.email ?? user.username;
return (
<li key={user.id}>
<div className="flex items-center">
<p>
{user.firstName || user.lastName ? (
<>
{`${`${user.firstName} ${user.lastName}`.trim()} `}(
<span className="break-all">{email}</span>)
</>
) : (
<span className="break-all">{email}</span>
)}{" "}
</p>
{userId === user.id && (
<Tooltip content="Are you sure you want to remove yourself from this instance?">
<div className="inline-block">
<Badge
variant="primary"
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faWarning} />
<span>Removing Yourself</span>
</Badge>
</div>
</Tooltip>
)}
</div>
</li>
);
})}
</ul>
</div>
</DeleteActionModal>
</div>
</>
);
};

View File

@@ -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 (
<div>