feat(campaigns): delete campaign button + defer Jira to Activate
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Backend: add DELETE /campaigns/{id}?delete_tests=bool endpoint
- Backend: add delete_campaign() service — handles draft-only restriction,
optional test deletion, nullifies child campaign FKs
- Backend: remove early Jira ticket creation from POST /campaigns,
POST /campaigns/{id}/tests, and POST /campaigns/from-threat-actor
- Backend: activate endpoint now creates campaign Jira ticket if missing,
then creates test tickets (all deferred from creation to activation)
- Frontend: add deleteCampaign() API function to campaigns.ts
- Frontend: two-step confirmation dialog on CampaignDetailPage —
first confirms deletion, then asks whether to also delete associated tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,16 @@ export async function scheduleCampaign(
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Delete a campaign. Only draft campaigns can be deleted (admins can delete any). */
|
||||
export async function deleteCampaign(
|
||||
campaignId: string,
|
||||
deleteTests: boolean = false,
|
||||
): Promise<void> {
|
||||
await client.delete(`/campaigns/${campaignId}`, {
|
||||
params: { delete_tests: deleteTests },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get execution history (child campaigns) for a recurring campaign. */
|
||||
export async function getCampaignHistory(campaignId: string): Promise<{
|
||||
campaign_id: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getCampaign,
|
||||
activateCampaign,
|
||||
completeCampaign,
|
||||
deleteCampaign,
|
||||
removeTestFromCampaign,
|
||||
scheduleCampaign,
|
||||
getCampaignHistory,
|
||||
@@ -63,6 +64,8 @@ export default function CampaignDetailPage() {
|
||||
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
const [showAddTestModal, setShowAddTestModal] = useState(false);
|
||||
// 0 = hidden, 1 = first confirmation, 2 = ask about tests
|
||||
const [deleteStep, setDeleteStep] = useState<0 | 1 | 2>(0);
|
||||
|
||||
const showToast = (message: string, type: "success" | "error") => {
|
||||
setToast({ message, type });
|
||||
@@ -120,6 +123,18 @@ export default function CampaignDetailPage() {
|
||||
onError: (err: Error) => showToast(err.message, "error"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (deleteTests: boolean) => deleteCampaign(campaignId!, deleteTests),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||||
navigate("/campaigns");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setDeleteStep(0);
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const { data: historyData } = useQuery({
|
||||
queryKey: ["campaign-history", campaignId],
|
||||
queryFn: () => getCampaignHistory(campaignId!),
|
||||
@@ -249,6 +264,17 @@ export default function CampaignDetailPage() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Delete — only for draft campaigns (admins see it regardless) */}
|
||||
{(campaign.status === "draft" || role === "admin") && canManage && (
|
||||
<button
|
||||
onClick={() => setDeleteStep(1)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 transition-colors"
|
||||
title="Delete campaign"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{canManage && campaign.status === "draft" && (
|
||||
<button
|
||||
onClick={() => activateMutation.mutate()}
|
||||
@@ -629,6 +655,87 @@ export default function CampaignDetailPage() {
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation — Step 1 */}
|
||||
{deleteStep === 1 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-md rounded-xl border border-red-500/30 bg-gray-900 p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-red-500/10 p-2">
|
||||
<Trash2 className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Delete Campaign</h3>
|
||||
</div>
|
||||
<p className="mb-1 text-sm text-gray-300">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-white">{campaign.name}</span>?
|
||||
</p>
|
||||
<p className="mb-6 text-xs text-gray-500">This action cannot be undone.</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteStep(0)}
|
||||
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteStep(2)}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation — Step 2: ask about tests */}
|
||||
{deleteStep === 2 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-md rounded-xl border border-red-500/30 bg-gray-900 p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-red-500/10 p-2">
|
||||
<Trash2 className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Delete Associated Tests?</h3>
|
||||
</div>
|
||||
<p className="mb-6 text-sm text-gray-300">
|
||||
This campaign has{" "}
|
||||
<span className="font-semibold text-white">{campaign.tests.length}</span>{" "}
|
||||
associated test{campaign.tests.length !== 1 ? "s" : ""}. Do you also want to
|
||||
delete them?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteStep(0)}
|
||||
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(false)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg border border-gray-600 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Keep Tests
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete Tests Too
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user