Files
Aegis/frontend/src/pages/CampaignDetailPage.tsx
kitos bd0493aade
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
fix(ui): make all Jira and time panels read-only everywhere
WorklogTimeline: add readOnly prop — hides 'Log Time' button and form.
TestPhaseTimeline: remove 'Sync to Tempo' button from TempoSyncBadge;
  only displays the green 'Tempo' badge when already synced. Cleans up
  unused imports (useState, useMutation, useQueryClient, syncTestToTempo).
CampaignDetailPage: JiraLinkPanel and WorklogTimeline both now rendered
  with readOnly=true; JiraLinkPanel receives campaign name as label.

Jira tickets and time worklogs are created automatically by the system
(campaign activation, test workflow) — no manual editing from detail pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:33:55 +02:00

743 lines
30 KiB
TypeScript

import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import MarkdownText from "../components/MarkdownText";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
ArrowLeft,
Play,
CheckCircle,
Target,
Plus,
Trash2,
Zap,
Calendar,
Clock,
Repeat,
History,
} from "lucide-react";
import {
getCampaign,
activateCampaign,
completeCampaign,
deleteCampaign,
removeTestFromCampaign,
scheduleCampaign,
getCampaignHistory,
type Campaign,
type CampaignHistoryEntry,
} from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline";
import JiraLinkPanel from "../components/JiraLinkPanel";
import WorklogTimeline from "../components/WorklogTimeline";
import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
active: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
completed: "bg-green-900/50 text-green-400 border-green-500/30",
archived: "bg-gray-800/50 text-gray-500 border-gray-700/30",
};
const typeLabels: Record<string, string> = {
custom: "Custom",
apt_emulation: "APT Emulation",
kill_chain: "Kill Chain",
compliance: "Compliance",
};
const testStateColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
};
export default function CampaignDetailPage() {
const { campaignId } = useParams<{ campaignId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
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 });
setTimeout(() => setToast(null), 5000);
};
const role = user?.role ?? "";
const canManage = role === "admin" || role === "red_lead" || role === "blue_lead";
const canComplete = role === "admin" || role === "red_lead";
const {
data: campaign,
isLoading,
error,
} = useQuery({
queryKey: ["campaign", campaignId],
queryFn: () => getCampaign(campaignId!),
enabled: !!campaignId,
});
const activateMutation = useMutation({
mutationFn: () => activateCampaign(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Campaign activated", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const completeMutation = useMutation({
mutationFn: () => completeCampaign(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Campaign completed", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const removeMutation = useMutation({
mutationFn: (campaignTestId: string) => removeTestFromCampaign(campaignId!, campaignTestId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Test removed from campaign", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const scheduleMutation = useMutation({
mutationFn: (payload: { is_recurring: boolean; recurrence_pattern?: string; next_run_at?: string }) =>
scheduleCampaign(campaignId!, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Schedule updated", "success");
},
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!),
enabled: !!campaignId && !!campaign?.is_recurring,
});
const [schedRecurring, setSchedRecurring] = useState(false);
const [schedPattern, setSchedPattern] = useState("monthly");
const [schedNextRun, setSchedNextRun] = useState("");
// Sync scheduling state from campaign when loaded
useState(() => {
if (campaign) {
setSchedRecurring(campaign.is_recurring || false);
setSchedPattern(campaign.recurrence_pattern || "monthly");
setSchedNextRun(campaign.next_run_at ? campaign.next_run_at.slice(0, 16) : "");
}
});
const handleScheduleSave = () => {
if (schedRecurring) {
scheduleMutation.mutate({
is_recurring: true,
recurrence_pattern: schedPattern,
next_run_at: schedNextRun || undefined,
});
} else {
scheduleMutation.mutate({ is_recurring: false });
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return "\u2014";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
if (error || !campaign) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load campaign</p>
<button
onClick={() => navigate("/campaigns")}
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
>
<ArrowLeft className="h-4 w-4" />
Back to campaigns
</button>
</div>
);
}
const progress = campaign.progress;
return (
<div className="space-y-6">
{/* Back button */}
<button
onClick={() => navigate("/campaigns")}
className="flex items-center gap-1 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to campaigns
</button>
{/* Header */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-cyan-500/10 p-3">
<Zap className="h-8 w-8 text-cyan-400" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">{campaign.name}</h1>
<span
className={`inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium ${
statusColors[campaign.status] || statusColors.draft
}`}
>
{campaign.status}
</span>
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2.5 py-0.5 text-xs font-medium text-gray-400">
{typeLabels[campaign.type] || campaign.type}
</span>
</div>
{campaign.description && (
<MarkdownText content={campaign.description} className="mt-1 text-sm text-gray-400" />
)}
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
{campaign.threat_actor_name && (
<button
onClick={() => navigate(`/threat-actors/${campaign.threat_actor_id}`)}
className="flex items-center gap-1 text-red-400 hover:underline"
>
<Target className="h-3.5 w-3.5" />
{campaign.threat_actor_name}
</button>
)}
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
Created {formatDate(campaign.created_at)}
</span>
{campaign.completed_at && (
<span className="flex items-center gap-1 text-green-400">
<CheckCircle className="h-3.5 w-3.5" />
Completed {formatDate(campaign.completed_at)}
</span>
)}
</div>
</div>
</div>
{/* 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()}
disabled={activateMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{activateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Activate
</button>
)}
{canComplete && campaign.status === "active" && (
<button
onClick={() => completeMutation.mutate()}
disabled={completeMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 disabled:opacity-50 transition-colors"
>
{completeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle className="h-4 w-4" />
)}
Mark Completed
</button>
)}
</div>
</div>
</div>
{/* Progress Panel */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Progress</h2>
<div className="flex items-center gap-6 mb-4">
<div>
<span className="text-3xl font-bold text-white">{progress.completion_pct}%</span>
<span className="ml-1 text-sm text-gray-400">complete</span>
</div>
<div className="flex-1">
<div className="h-3 w-full rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
progress.completion_pct === 100 ? "bg-green-500" : "bg-cyan-500"
}`}
style={{ width: `${progress.completion_pct}%` }}
/>
</div>
</div>
<span className="text-sm text-gray-400">
{progress.by_state?.validated || 0} / {progress.total} tests
</span>
</div>
{/* State breakdown */}
{progress.total > 0 && (
<div className="flex flex-wrap gap-3">
{Object.entries(progress.by_state || {}).map(([state, count]) => (
<div
key={state}
className={`rounded-lg border px-3 py-1.5 text-xs font-medium ${
testStateColors[state] || testStateColors.draft
}`}
>
{state.replace(/_/g, " ")}: {count}
</div>
))}
</div>
)}
</div>
{/* Kill Chain Timeline */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Kill Chain Timeline</h2>
{campaign.tags && campaign.tags.length > 0 && (
<div className="flex gap-1">
{campaign.tags.map((tag, i) => (
<span
key={i}
className="rounded-full bg-gray-800 border border-gray-700 px-2 py-0.5 text-[10px] text-gray-400"
>
{tag}
</span>
))}
</div>
)}
</div>
<CampaignTimeline tests={campaign.tests} />
</div>
{/* Scheduling Panel */}
{(canManage || campaign.is_recurring) && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center gap-2">
<Repeat className="h-5 w-5 text-cyan-400" />
<h2 className="text-lg font-semibold text-white">Scheduling</h2>
{campaign.next_run_at && (
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-cyan-500/30 bg-cyan-900/30 px-3 py-1 text-xs font-medium text-cyan-400">
<Calendar className="h-3.5 w-3.5" />
Next run: {formatDate(campaign.next_run_at)}
</span>
)}
</div>
{canManage && (
<div className="space-y-4">
{/* Recurring toggle */}
<label className="flex items-center gap-3 cursor-pointer">
<div
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
schedRecurring ? "bg-cyan-600" : "bg-gray-700"
}`}
onClick={() => setSchedRecurring(!schedRecurring)}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
schedRecurring ? "translate-x-5" : "translate-x-0"
}`}
/>
</div>
<span className="text-sm font-medium text-gray-300">
Recurring Campaign
</span>
</label>
{schedRecurring && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
Frequency
</label>
<select
value={schedPattern}
onChange={(e) => setSchedPattern(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
Next Run At
</label>
<input
type="datetime-local"
value={schedNextRun}
onChange={(e) => setSchedNextRun(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
/>
</div>
</div>
)}
<button
onClick={handleScheduleSave}
disabled={scheduleMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{scheduleMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save Schedule
</button>
</div>
)}
{!canManage && campaign.is_recurring && (
<div className="text-sm text-gray-400">
This campaign runs <span className="text-white font-medium">{campaign.recurrence_pattern}</span>.
{campaign.last_run_at && (
<span className="ml-1">Last run: {formatDate(campaign.last_run_at)}</span>
)}
</div>
)}
</div>
)}
{/* Execution History */}
{campaign.is_recurring && historyData && historyData.items.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center gap-2">
<History className="h-5 w-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">
Execution History ({historyData.items.length})
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">Date</th>
<th className="pb-3 px-4 font-medium text-gray-400">Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Tests</th>
<th className="pb-3 px-4 font-medium text-gray-400">Progress</th>
<th className="pb-3 px-4 font-medium text-gray-400">Status</th>
</tr>
</thead>
<tbody>
{historyData.items.map((entry: CampaignHistoryEntry) => (
<tr
key={entry.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => navigate(`/campaigns/${entry.id}`)}
>
<td className="py-3 pr-4 text-xs text-gray-400">
{formatDate(entry.created_at)}
</td>
<td className="py-3 px-4 text-sm text-gray-200">
{entry.name}
</td>
<td className="py-3 px-4 text-sm text-gray-400">
{entry.test_count}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<div className="h-2 w-20 rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full ${
entry.completion_pct === 100 ? "bg-green-500" : "bg-cyan-500"
}`}
style={{ width: `${entry.completion_pct}%` }}
/>
</div>
<span className="text-xs text-gray-400">{entry.completion_pct}%</span>
</div>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
statusColors[entry.status] || statusColors.draft
}`}
>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Tests Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
Tests ({campaign.tests.length})
</h2>
{canManage && campaign.status === "draft" && (
<button
onClick={() => setShowAddTestModal(true)}
className="flex items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<Plus className="h-4 w-4" />
Add Test
</button>
)}
</div>
{campaign.tests.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">#</th>
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
<th className="pb-3 px-4 font-medium text-gray-400">Test Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Phase</th>
<th className="pb-3 px-4 font-medium text-gray-400">State</th>
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th>
<th className="pb-3 pl-4 font-medium text-gray-400">Actions</th>
</tr>
</thead>
<tbody>
{campaign.tests
.sort((a, b) => a.order_index - b.order_index)
.map((ct) => (
<tr
key={ct.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="py-3 pr-4">
<span className="text-xs text-gray-500">{ct.order_index + 1}</span>
</td>
<td className="py-3 px-4">
<span className="font-mono text-xs text-cyan-400">
{ct.technique_mitre_id || "\u2014"}
</span>
</td>
<td className="py-3 px-4">
<button
onClick={() => navigate(`/tests/${ct.test_id}`)}
className="text-sm font-medium text-gray-200 hover:text-cyan-400 transition-colors"
>
{ct.test_name || "Unnamed test"}
</button>
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400 capitalize">
{ct.phase?.replace(/_/g, " ") || "\u2014"}
</span>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
testStateColors[ct.test_state || "draft"] || testStateColors.draft
}`}
>
{(ct.test_state || "draft").replace(/_/g, " ")}
</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400 capitalize">
{ct.platform || "\u2014"}
</span>
</td>
<td className="py-3 pl-4">
<div className="flex items-center gap-2">
<button
onClick={() => navigate(`/tests/${ct.test_id}`)}
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-cyan-400"
title="View Test"
>
<Clock className="h-4 w-4" />
</button>
{canManage && (campaign.status === "draft" || campaign.status === "active") && (
<button
onClick={() => removeMutation.mutate(ct.id)}
disabled={removeMutation.isPending}
className="rounded p-1 text-gray-400 hover:bg-red-900/50 hover:text-red-400"
title="Remove from campaign"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<Zap className="mb-2 h-8 w-8 text-gray-600" />
<p>No tests in this campaign yet.</p>
</div>
)}
</div>
{/* Jira & Worklogs — read-only, automatically managed */}
<div className="grid gap-6 lg:grid-cols-2">
<JiraLinkPanel entityType="campaign" entityId={campaignId!} readOnly label={campaign.name} />
<WorklogTimeline entityType="campaign" entityId={campaignId!} readOnly />
</div>
{/* Add Test to Campaign Modal */}
<AddTestToCampaignModal
campaignId={campaignId!}
existingTestIds={campaign.tests.map((ct) => ct.test_id)}
open={showAddTestModal}
onClose={() => setShowAddTestModal(false)}
onSuccess={() => showToast("Test added to campaign", "success")}
/>
{/* Toast notification */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur transition-all ${
toast.type === "success"
? "border-green-500/30 bg-green-900/90 text-green-300"
: "border-red-500/30 bg-red-900/90 text-red-300"
}`}
>
{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>
);
}