From 4f00fc6777d24462144dc553a045370acfef7eb0 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 29 Jul 2025 16:42:13 -0700 Subject: [PATCH] improvement: add bulk delete org members endpoint and table support --- backend/src/lib/api-docs/constants.ts | 4 + .../server/routes/v2/organization-router.ts | 42 ++ backend/src/services/org/org-dal.ts | 16 + backend/src/services/org/org-fns.ts | 107 +++- backend/src/services/org/org-service.ts | 46 +- backend/src/services/org/org-types.ts | 8 + .../project-membership-dal.ts | 114 ++++- .../organizations/bulk-delete-memberships.mdx | 4 + docs/docs.json | 1 + frontend/src/hooks/api/users/queries.tsx | 24 +- frontend/src/hooks/api/users/types.ts | 7 +- .../OrgMembersSection/OrgMembersSection.tsx | 271 ++++++++--- .../OrgMembersSection/OrgMembersTable.tsx | 459 ++++++++++-------- .../SelectionPanel/SelectionPanel.tsx | 18 +- .../components/ActionBar/ActionBar.tsx | 17 +- 15 files changed, 835 insertions(+), 303 deletions(-) create mode 100644 docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 71b6afb6b..572ea5ca9 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -664,6 +664,10 @@ export const ORGANIZATIONS = { organizationId: "The ID of the organization to delete the membership from.", membershipId: "The ID of the membership to delete." }, + BULK_DELETE_USER_MEMBERSHIPS: { + organizationId: "The ID of the organization to delete the memberships from.", + membershipIds: "The IDs of the memberships to delete." + }, LIST_IDENTITY_MEMBERSHIPS: { orgId: "The ID of the organization to get identity memberships from.", offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.", diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index c17200a30..87e1d1789 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -264,6 +264,48 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/:organizationId/memberships", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Organizations], + description: "Bulk delete organization user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.BULK_DELETE_USER_MEMBERSHIPS.organizationId) + }), + body: z.object({ + membershipIds: z.string().trim().array().describe(ORGANIZATIONS.BULK_DELETE_USER_MEMBERSHIPS.membershipIds) + }), + response: { + 200: z.object({ + memberships: OrgMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + if (req.auth.actor !== ActorType.USER) return; + + const memberships = await server.services.org.bulkDeleteOrgMemberships({ + userId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + orgId: req.params.organizationId, + membershipIds: req.body.membershipIds, + actorOrgId: req.permission.orgId + }); + return { memberships }; + } + }); + server.route({ // TODO: re-think endpoint structure in future so users only need to pass in membershipId bc organizationId is redundant method: "GET", diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 5730c0d92..b96e800a7 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -513,6 +513,21 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const deleteMembershipsById = async (ids: string[], orgId: string, tx?: Knex) => { + try { + const memberships = await (tx || db)(TableName.OrgMembership) + .where({ + orgId + }) + .whereIn("id", ids) + .delete() + .returning("*"); + return memberships; + } catch (error) { + throw new DatabaseError({ error, name: "Delete org memberships" }); + } + }; + const findMembership = async ( filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {} @@ -634,6 +649,7 @@ export const orgDALFactory = (db: TDbClient) => { createMembership, updateMembershipById, deleteMembershipById, + deleteMembershipsById, updateMembership }); }; diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index 559ccc7db..6d959c3a3 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -1,5 +1,6 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; +import { BadRequestError } from "@app/lib/errors"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -14,6 +15,19 @@ type TDeleteOrgMembership = { userAliasDAL: Pick; licenseService: Pick; projectUserAdditionalPrivilegeDAL: Pick; + userId: string; +}; + +type TDeleteOrgMemberships = { + orgMembershipIds: string[]; + orgId: string; + orgDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + userAliasDAL: Pick; + licenseService: Pick; + projectUserAdditionalPrivilegeDAL: Pick; + userId: string; }; export const deleteOrgMembershipFn = async ({ @@ -24,11 +38,17 @@ export const deleteOrgMembershipFn = async ({ projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, - licenseService + licenseService, + userId }: TDeleteOrgMembership) => { const deletedMembership = await orgDAL.transaction(async (tx) => { const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); + if (orgMembership.userId === userId) { + // scott: this is temporary, we will add a leave org endpoint with proper handling to ensure org isn't abandoned/broken + throw new BadRequestError({ message: "You cannot remove yourself from an organization" }); + } + if (!orgMembership.userId) { await licenseService.updateSubscriptionOrgMemberCount(orgId); return orgMembership; @@ -86,3 +106,88 @@ export const deleteOrgMembershipFn = async ({ return deletedMembership; }; + +export const deleteOrgMembershipsFn = async ({ + orgMembershipIds, + orgId, + orgDAL, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, + projectKeyDAL, + userAliasDAL, + licenseService, + userId +}: TDeleteOrgMemberships) => { + const deletedMemberships = await orgDAL.transaction(async (tx) => { + const orgMemberships = await orgDAL.deleteMembershipsById(orgMembershipIds, orgId, tx); + + const membershipUserIds = orgMemberships + .filter((member) => Boolean(member.userId)) + .map((member) => member.userId) as string[]; + + if (membershipUserIds.includes(userId)) { + // scott: this is temporary, we will add a leave org endpoint with proper handling to ensure org isn't abandoned/broken + throw new BadRequestError({ message: "You cannot remove yourself from an organization" }); + } + + if (!membershipUserIds.length) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMemberships; + } + + await userAliasDAL.delete( + { + $in: { + userId: membershipUserIds + }, + orgId + }, + tx + ); + + await projectUserAdditionalPrivilegeDAL.delete( + { + $in: { + userId: membershipUserIds + } + }, + tx + ); + + // Get all the project memberships of the users in the organization + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, membershipUserIds); + + // Delete all the project memberships of the users in the organization + await projectMembershipDAL.delete( + { + $in: { + id: projectMemberships.map((membership) => membership.id) + } + }, + tx + ); + + // Get all the project keys of the user in the organization + const projectKeys = await projectKeyDAL.find({ + $in: { + projectId: projectMemberships.map((membership) => membership.projectId), + receiverId: membershipUserIds + } + }); + + // Delete all the project keys of the user in the organization + await projectKeyDAL.delete( + { + $in: { + id: projectKeys.map((key) => key.id) + } + }, + tx + ); + + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMemberships; + }); + + return deletedMemberships; +}; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 5f60bfe1e..ca51ff57a 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -75,10 +75,11 @@ import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; -import { deleteOrgMembershipFn } from "./org-fns"; +import { deleteOrgMembershipFn, deleteOrgMembershipsFn } from "./org-fns"; import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, + TDeleteOrgMembershipsDTO, TFindAllWorkspacesDTO, TFindOrgMembersByEmailDTO, TGetOrgGroupsDTO, @@ -106,7 +107,13 @@ type TOrgServiceFactoryDep = { identityMetadataDAL: Pick; projectMembershipDAL: Pick< TProjectMembershipDALFactory, - "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" + | "findProjectMembershipsByUserId" + | "delete" + | "create" + | "find" + | "insertMany" + | "transaction" + | "findProjectMembershipsByUserIds" >; projectKeyDAL: Pick; orgMembershipDAL: Pick< @@ -1369,12 +1376,42 @@ export const orgServiceFactory = ({ projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, - licenseService + licenseService, + userId }); return deletedMembership; }; + const bulkDeleteOrgMemberships = async ({ + orgId, + userId, + membershipIds, + actorAuthMethod, + actorOrgId + }: TDeleteOrgMembershipsDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); + + if (membershipIds.includes(userId)) { + throw new BadRequestError({ message: "You cannot delete your own organization membership" }); + } + + const deletedMemberships = await deleteOrgMembershipsFn({ + orgMembershipIds: membershipIds, + orgId, + orgDAL, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, + projectKeyDAL, + userAliasDAL, + licenseService, + userId + }); + + return deletedMemberships; + }; + const listProjectMembershipsByOrgMembershipId = async ({ orgMembershipId, orgId, @@ -1528,6 +1565,7 @@ export const orgServiceFactory = ({ findOrgBySlug, resendOrgMemberInvitation, upgradePrivilegeSystem, - notifyInvitedUsers + notifyInvitedUsers, + bulkDeleteOrgMemberships }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 0736f174d..645692145 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -25,6 +25,14 @@ export type TDeleteOrgMembershipDTO = { actorAuthMethod: ActorAuthMethod; }; +export type TDeleteOrgMembershipsDTO = { + userId: string; + orgId: string; + membershipIds: string[]; + actorOrgId: string | undefined; + actorAuthMethod: ActorAuthMethod; +}; + export type TInviteUserToOrgDTO = { inviteeEmails: string[]; organizationRoleSlug: string; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 8315ed429..488f52b90 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -314,11 +314,123 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } }; + const findProjectMembershipsByUserIds = async (orgId: string, userIds: string[]) => { + try { + const docs = await db + .replicaNode()(TableName.ProjectMembership) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .whereIn(`${TableName.Users}.id`, userIds) + .where(`${TableName.Project}.orgId`, orgId) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("userId").withSchema(TableName.ProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), + db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("id").as("projectId").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project) + ) + .where({ isGhost: false }); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ + email, + firstName, + username, + lastName, + publicKey, + isGhost, + id, + projectId, + projectName, + projectType, + userId + }) => ({ + id, + userId, + projectId, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + project: { + id: projectId, + name: projectName, + type: projectType + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; + } catch (error) { + throw new DatabaseError({ error, name: "Find project memberships by user ids" }); + } + }; + return { ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, findMembershipsByUsername, - findProjectMembershipsByUserId + findProjectMembershipsByUserId, + findProjectMembershipsByUserIds }; }; diff --git a/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx b/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx new file mode 100644 index 000000000..70793572c --- /dev/null +++ b/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "Bulk Delete User Memberships" +openapi: "DELETE /api/v2/organizations/{organizationId}/memberships" +--- diff --git a/docs/docs.json b/docs/docs.json index ebbc31500..070ed3085 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -900,6 +900,7 @@ "api-reference/endpoints/organizations/memberships", "api-reference/endpoints/organizations/update-membership", "api-reference/endpoints/organizations/delete-membership", + "api-reference/endpoints/organizations/bulk-delete-memberships", "api-reference/endpoints/organizations/list-identity-memberships", "api-reference/endpoints/organizations/workspaces" ] diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index b740b58ee..75edc0907 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -17,7 +17,8 @@ import { APIKeyData, AuthMethod, CreateAPIKeyRes, - DeletOrgMembershipDTO, + DeleteOrgMembershipBatchDTO, + DeleteOrgMembershipDTO, OrgUser, RenameUserDTO, TokenVersion, @@ -243,7 +244,7 @@ export const useGetOrgMembershipProjectMemberships = ( export const useDeleteOrgMembership = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ membershipId, orgId }) => { return apiRequest.delete(`/api/v2/organizations/${orgId}/memberships/${membershipId}`); }, @@ -253,10 +254,27 @@ export const useDeleteOrgMembership = () => { }); }; +export const useDeleteOrgMembershipBatch = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ membershipIds, orgId }) => { + return apiRequest.delete(`/api/v2/organizations/${orgId}/memberships`, { + data: { + membershipIds + } + }); + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries({ queryKey: userKeys.getOrgUsers(orgId) }); + } + }); +}; + export const useDeactivateOrgMembership = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ membershipId, orgId }) => { return apiRequest.post( `/api/v2/organizations/${orgId}/memberships/${membershipId}/deactivate` diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 963ab100e..48da6f7d1 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -143,11 +143,16 @@ export type UpdateOrgMembershipDTO = { metadata?: { key: string; value: string }[]; }; -export type DeletOrgMembershipDTO = { +export type DeleteOrgMembershipDTO = { membershipId: string; orgId: string; }; +export type DeleteOrgMembershipBatchDTO = { + membershipIds: string[]; + orgId: string; +}; + export type AddUserToOrgDTO = { inviteeEmails: string[]; organizationRoleSlug: string; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index 19f05f763..dc5d9c368 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -1,18 +1,28 @@ import { useState } from "react"; -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faBan, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal, EmailServiceSetupModal } from "@app/components/v2"; +import { + Badge, + Button, + DeleteActionModal, + EmailServiceSetupModal, + Tooltip +} from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization, - useSubscription + useSubscription, + useUser } from "@app/context"; -import { useDeleteOrgMembership, useUpdateOrgMembership } from "@app/hooks/api"; +import { useDeleteOrgMembership, useGetOrgUsers, useUpdateOrgMembership } from "@app/hooks/api"; +import { useDeleteOrgMembershipBatch } from "@app/hooks/api/users/queries"; +import { OrgUser } from "@app/hooks/api/users/types"; import { usePopUp } from "@app/hooks/usePopUp"; import { AddOrgMemberModal } from "./AddOrgMemberModal"; @@ -22,7 +32,8 @@ export const OrgMembersSection = () => { const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id ?? ""; - + const { user } = useUser(); + const userId = user?.id || ""; const [completeInviteLinks, setCompleteInviteLinks] = useState { "removeMember", "deactivateMember", "upgradePlan", - "setUpEmail" + "setUpEmail", + "removeMembers" ] as const); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership(); + const { mutateAsync: deleteBatchMutateAsync } = useDeleteOrgMembershipBatch(); const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const isMoreIdentitiesAllowed = subscription?.identityLimit @@ -109,70 +124,188 @@ export const OrgMembersSection = () => { handlePopUpClose("removeMember"); }; + const { data: members = [] } = useGetOrgUsers(orgId); + + const handleRemoveMembers = async (selectedMembers: OrgUser[]) => { + try { + await deleteBatchMutateAsync({ + orgId, + membershipIds: selectedMembers + .filter((member) => member.user.id !== userId) + .map((member) => member.id) + }); + + createNotification({ + text: "Successfully removed users from organization", + type: "success" + }); + + setSelectedMemberIds([]); + handlePopUpClose("removeMembers"); + } catch { + createNotification({ + text: "Failed to remove users from the organization", + type: "error" + }); + } + }; + return ( -
-
-

Users

- - {(isAllowed) => ( - - )} - + <> +
0 && "h-16" + )} + > +
+
{selectedMemberIds.length > 0} Selected
+ + + {(isAllowed) => ( + + )} + +
- - - handlePopUpToggle("removeMember", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onRemoveMemberSubmit( - (popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId - ) - } - /> - handlePopUpToggle("deactivateMember", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onDeactivateMemberSubmit( - (popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId - ) - } - buttonText="Deactivate" - /> - handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - /> - handlePopUpToggle("setUpEmail", isOpen)} - /> -
+
+
+

Users

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("removeMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveMemberSubmit( + (popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + /> + handlePopUpToggle("deactivateMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeactivateMemberSubmit( + (popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + buttonText="Deactivate" + /> + handlePopUpToggle("removeMembers", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + handleRemoveMembers(popUp.removeMembers.data.selectedOrgMemberships as OrgUser[]) + } + buttonText="Remove" + > +
+ The following members will be removed: +
+
+
    + {(popUp.removeMembers.data?.selectedOrgMemberships as OrgUser[])?.map((member) => { + const email = member.user.email ?? member.user.username ?? member.inviteEmail; + return ( +
  • + + {member.user.firstName || member.user.lastName + ? `${`${member.user.firstName} ${member.user.lastName}`.trim()} (${email})` + : email} + + {userId === member.user.id && ( + +
    + + + Ignored + +
    +
    + )} +
  • + ); + })} +
+
+
+ handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + handlePopUpToggle("setUpEmail", isOpen)} + /> +
+ ); }; 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 c323c4057..45bc7ac8a 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 @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useMemo, useState } from "react"; import { faArrowDown, faArrowUp, @@ -22,6 +22,7 @@ import { OrgPermissionCan } from "@app/components/permissions"; import { Badge, Button, + Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -66,18 +67,24 @@ import { } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { useResendOrgMemberInvitation } from "@app/hooks/api/users/mutation"; +import { OrgUser } from "@app/hooks/api/users/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["removeMember", "deactivateMember", "upgradePlan"]>, + popUpName: keyof UsePopUpState< + ["removeMember", "deactivateMember", "upgradePlan", "removeMembers"] + >, data?: { orgMembershipId?: string; username?: string; description?: string; + selectedOrgMemberships?: OrgUser[]; } ) => void; setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void; + selectedMemberIds: string[]; + setSelectedMemberIds: Dispatch>; }; enum OrgMembersOrderBy { @@ -90,7 +97,12 @@ type Filter = { roles: string[]; }; -export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => { +export const OrgMembersTable = ({ + handlePopUpOpen, + setCompleteInviteLinks, + selectedMemberIds, + setSelectedMemberIds +}: Props) => { const navigate = useNavigate(); const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); @@ -208,7 +220,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro roles: [] }); - const filteredUsers = useMemo( + const filteredMembers = useMemo( () => members ?.filter(({ user: u, inviteEmail, role, roleId }) => { @@ -273,7 +285,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro }; useResetPageHelper({ - totalCount: filteredUsers.length, + totalCount: filteredMembers.length, offset, setPage }); @@ -293,6 +305,16 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro const isTableFiltered = Boolean(filter.roles.length); + const filteredMembersPage = filteredMembers.slice(offset, perPage * page); + + const isPageSelected = filteredMembersPage.every((member) => + selectedMemberIds.includes(member.id) + ); + + const isPageIndeterminate = isPageSelected + ? false + : filteredMembersPage.some((member) => selectedMemberIds.includes(member.id)); + return (
@@ -357,6 +379,24 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro + {isLoading && } {!isLoading && - filteredUsers - .slice(offset, perPage * page) - .map( - ({ - user: u, - inviteEmail, - role, - roleId, - id: orgMembershipId, - status, - isActive - }) => { - const name = - u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : null; - const email = u?.email || inviteEmail; - const username = u?.username ?? inviteEmail ?? "-"; - return ( - - navigate({ - to: "/organization/members/$membershipId" as const, - params: { - membershipId: orgMembershipId - } - }) - } - > - + navigate({ + to: "/organization/members/$membershipId" as const, + params: { + membershipId: orgMembershipId + } + }) + } + > + + + + - - + - - - ); - } - )} + handlePopUpOpen("removeMember", { + orgMembershipId, + username + }); + }} + isDisabled={!isAllowed} + icon={} + > + Remove User + + )} + + + + + + + ); + } + )}
+ { + if (isPageSelected) { + setSelectedMemberIds((prev) => + prev.filter((member) => !filteredMembersPage.find((m) => m.id !== member)) + ); + } else { + setSelectedMemberIds((prev) => [ + ...new Set([...prev, ...filteredMembersPage.map((member) => member.id)]) + ]); + } + }} + /> +
Name @@ -423,227 +463,232 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
- {name ?? Not Set} - {u.superAdmin && ( - - Server Admin - + filteredMembersPage.map( + ({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status, isActive }) => { + const name = + u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : null; + const email = u?.email || inviteEmail; + const username = u?.username ?? inviteEmail ?? "-"; + const isSelected = selectedMemberIds.includes(orgMembershipId); + return ( +
+ { + e.stopPropagation(); + setSelectedMemberIds((prev) => + isSelected + ? prev.filter((id) => id !== orgMembershipId) + : [...prev, orgMembershipId] + ); + }} + /> + + {name ?? Not Set} + {u.superAdmin && ( + + Server Admin + + )} + {username} + + {(isAllowed) => ( + )} - {username} - - {(isAllowed) => ( - +
+ {isActive && + (status === "invited" || status === "verified") && + email && + serverDetails?.emailConfigured && ( + - {(roles || []) - .filter(({ slug }) => - slug === "owner" ? isIamOwner || role === "owner" : true - ) - .map(({ slug, name: roleName }) => ( - - {roleName} - - ))} - + {(isAllowed) => ( + + )} + )} - -
-
- {isActive && - (status === "invited" || status === "verified") && - email && - serverDetails?.emailConfigured && ( - - {(isAllowed) => ( - - )} - - )} - - - - - - - - - {(isAllowed) => ( - { - e.stopPropagation(); - navigate({ - to: "/organization/members/$membershipId" as const, - params: { - membershipId: orgMembershipId - } + + if (!isActive) { + // activate user + await updateOrgMembership({ + organizationId: orgId, + membershipId: orgMembershipId, + isActive: true }); - }} - isDisabled={!isAllowed} - icon={} - > - Edit User - - )} - - - {(isAllowed) => ( - } - onClick={async (e) => { - e.stopPropagation(); - if (currentOrg?.scimEnabled) { - createNotification({ - text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", - type: "error" - }); - return; - } + return; + } - if (!isActive) { - // activate user - await updateOrgMembership({ - organizationId: orgId, - membershipId: orgMembershipId, - isActive: true - }); + // deactivate user + handlePopUpOpen("deactivateMember", { + orgMembershipId, + username + }); + }} + isDisabled={!isAllowed} + > + {`${isActive ? "Deactivate" : "Activate"} User`} + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); - return; - } - - // deactivate user - handlePopUpOpen("deactivateMember", { - orgMembershipId, - username + if (currentOrg?.scimEnabled && isActive) { + createNotification({ + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", + type: "error" }); - }} - isDisabled={!isAllowed} - > - {`${isActive ? "Deactivate" : "Activate"} User`} - - )} - - - {(isAllowed) => ( - { - e.stopPropagation(); + return; + } - if (currentOrg?.scimEnabled && isActive) { - createNotification({ - text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", - type: "error" - }); - return; - } - - handlePopUpOpen("removeMember", { - orgMembershipId, - username - }); - }} - isDisabled={!isAllowed} - icon={} - > - Remove User - - )} - - - -
-
- {Boolean(filteredUsers.length) && ( + {Boolean(filteredMembers.length) && ( )} - {!isMembersLoading && !filteredUsers?.length && ( + {!isMembersLoading && !filteredMembers?.length && (
- - - - - -
{selectedCount} Selected
+
{selectedCount} Selected
+ {isRotatedSecretSelected && ( Rotated Secrets will not be affected by action. diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index 8ff425f01..2fc2dc592 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -16,7 +16,6 @@ import { faFolderPlus, faKey, faLock, - faMinusSquare, faPaste, faPlus, faRotate, @@ -1061,14 +1060,14 @@ export const ActionBar = ({ )} >
- - - - - -
- {Object.keys(selectedSecrets).length} Selected -
+
{Object.keys(selectedSecrets).length} Selected
+