diff --git a/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx b/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx index 52d693351..0ab51981a 100644 --- a/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx +++ b/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx @@ -12,22 +12,29 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { formatError, type GraphQLError, sprintf } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { + ActionDropdown, Badge, Button, Card, + DropdownItem, IconPlusLarge, + IconTrashCan, Table, Tbody, Td, Th, Thead, Tr, + useConfirm, + useToast, } from "@probo/ui"; import type { PreloadedQuery } from "react-relay"; -import { graphql, usePaginationFragment, usePreloadedQuery } from "react-relay"; +import { graphql, useMutation, usePaginationFragment, usePreloadedQuery } from "react-relay"; +import type { AccessReviewCampaignsTabDeleteMutation } from "#/__generated__/core/AccessReviewCampaignsTabDeleteMutation.graphql"; import type { AccessReviewCampaignsTabFragment$key } from "#/__generated__/core/AccessReviewCampaignsTabFragment.graphql"; import type { AccessReviewCampaignsTabPaginationQuery } from "#/__generated__/core/AccessReviewCampaignsTabPaginationQuery.graphql"; import type { AccessReviewCampaignsTabQuery } from "#/__generated__/core/AccessReviewCampaignsTabQuery.graphql"; @@ -71,12 +78,24 @@ const campaignsFragment = graphql` name status createdAt + canDelete: permission(action: "core:access-review-campaign:delete") } } } } `; +const deleteCampaignMutation = graphql` + mutation AccessReviewCampaignsTabDeleteMutation( + $input: DeleteAccessReviewCampaignInput! + $connections: [ID!]! + ) { + deleteAccessReviewCampaign(input: $input) { + deletedAccessReviewCampaignId @deleteEdge(connections: $connections) + } + } +`; + type Props = { queryRef: PreloadedQuery; }; @@ -84,6 +103,8 @@ type Props = { export default function AccessReviewCampaignsTab({ queryRef }: Props) { const { __, dateFormat } = useTranslate(); const organizationId = useOrganizationId(); + const confirm = useConfirm(); + const { toast } = useToast(); const { organization } = usePreloadedQuery(accessReviewCampaignsTabQuery, queryRef); if (organization.__typename !== "Organization") { @@ -100,6 +121,65 @@ export default function AccessReviewCampaignsTab({ queryRef }: Props) { AccessReviewCampaignsTabFragment$key >(campaignsFragment, organization); + const [deleteCampaign] = useMutation( + deleteCampaignMutation, + ); + + // Only DRAFT and CANCELLED campaigns can be deleted (enforced by the backend). + const isDeletableStatus = (status: string) => + status === "DRAFT" || status === "CANCELLED"; + + const handleDelete = (campaignId: string, campaignName: string) => { + confirm( + () => { + deleteCampaign({ + variables: { + input: { accessReviewCampaignId: campaignId }, + connections: [accessReviewCampaigns.__id], + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to delete campaign"), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Campaign deleted successfully."), + variant: "success", + }); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to delete campaign"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }, + { + message: sprintf( + __("This will permanently delete \"%s\". This action cannot be undone."), + campaignName, + ), + }, + ); + }; + + const hasActions = accessReviewCampaigns.edges.some( + edge => edge.node.canDelete && isDeletableStatus(edge.node.status), + ); + return (
@@ -124,25 +204,49 @@ export default function AccessReviewCampaignsTab({ queryRef }: Props) { {__("Name")} {__("Status")} {__("Created at")} + {hasActions && } - {accessReviewCampaigns.edges.map(edge => ( - - {edge.node.name} - - - {statusLabel(__, edge.node.status)} - - - - {dateFormat(edge.node.createdAt)} - - - ))} + {accessReviewCampaigns.edges.map((edge) => { + const canDeleteRow + = edge.node.canDelete && isDeletableStatus(edge.node.status); + return ( + + {edge.node.name} + + + {statusLabel(__, edge.node.status)} + + + + {dateFormat(edge.node.createdAt)} + + {hasActions && ( + + {canDeleteRow && ( + + { + e.preventDefault(); + e.stopPropagation(); + handleDelete(edge.node.id, edge.node.name); + }} + > + {__("Delete")} + + + )} + + )} + + ); + })} diff --git a/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx index a5b613a55..c6f13cb5f 100644 --- a/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx +++ b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx @@ -29,6 +29,7 @@ import { IconChevronRight, IconPlusLarge, IconRobot, + IconTrashCan, Option, Select, Tbody, @@ -43,11 +44,13 @@ import { import * as Popover from "@radix-ui/react-popover"; import { useEffect, useMemo, useRef, useState } from "react"; import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironment } from "react-relay"; +import { useNavigate } from "react-router"; import { fetchQuery, graphql } from "relay-runtime"; import type { AccessEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql"; import type { AccessEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql"; import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql"; +import type { CampaignDetailPageDeleteMutation } from "#/__generated__/core/CampaignDetailPageDeleteMutation.graphql"; import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql"; import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; @@ -95,6 +98,16 @@ const closeCampaignMutation = graphql` } `; +const deleteCampaignMutation = graphql` + mutation CampaignDetailPageDeleteMutation( + $input: DeleteAccessReviewCampaignInput! + ) { + deleteAccessReviewCampaign(input: $input) { + deletedAccessReviewCampaignId + } + } +`; + const bulkDecisionMutation = graphql` mutation CampaignDetailPageBulkDecisionMutation( $input: RecordAccessEntryDecisionsInput! @@ -131,6 +144,7 @@ export const campaignDetailPageQuery = graphql` id name status + canDelete: permission(action: "core:access-review-campaign:delete") scopeSources { id source { @@ -171,6 +185,7 @@ type Props = { export default function CampaignDetailPage({ queryRef }: Props) { const { __ } = useTranslate(); const organizationId = useOrganizationId(); + const navigate = useNavigate(); const environment = useRelayEnvironment(); const data = usePreloadedQuery(campaignDetailPageQuery, queryRef); @@ -183,6 +198,8 @@ export default function CampaignDetailPage({ queryRef }: Props) { const isInProgress = campaign.status === "IN_PROGRESS"; const isDraft = campaign.status === "DRAFT"; const isPendingActions = campaign.status === "PENDING_ACTIONS"; + const isCancelled = campaign.status === "CANCELLED"; + const canDelete = campaign.canDelete && (isDraft || isCancelled); const campaignIdRef = useRef(campaign.id); @@ -216,6 +233,9 @@ export default function CampaignDetailPage({ queryRef }: Props) { const [closeCampaign, isClosing] = useMutation(closeCampaignMutation); + const [deleteCampaign, isDeleting] + = useMutation(deleteCampaignMutation); + const allDecided = campaign.scopeSources.length > 0 && campaign.scopeSources.every(source => source.entries @@ -262,6 +282,59 @@ export default function CampaignDetailPage({ queryRef }: Props) { }); }; + const handleDelete = () => { + confirm( + () => + new Promise((resolve) => { + deleteCampaign({ + variables: { + input: { accessReviewCampaignId: campaign.id }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to delete campaign"), + errors as GraphQLError[], + ), + variant: "error", + }); + resolve(); + return; + } + toast({ + title: __("Success"), + description: __("Campaign deleted successfully."), + variant: "success", + }); + resolve(); + navigate(`/organizations/${organizationId}/access-reviews`); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to delete campaign"), + error as GraphQLError, + ), + variant: "error", + }); + resolve(); + }, + }); + }), + { + message: sprintf( + __("This will permanently delete \"%s\". This action cannot be undone."), + campaign.name, + ), + label: __("Delete"), + variant: "danger", + }, + ); + }; + const handleComplete = () => { confirm( () => @@ -338,6 +411,17 @@ export default function CampaignDetailPage({ queryRef }: Props) { {isClosing ? __("Completing...") : __("Complete campaign")} )} + {canDelete && ( + + )}