Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
useMutation<Campaign, unknown, boolean> to fix TS2322/TS1345/TS2345 errors caused by inferred void variables type. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
807 lines
34 KiB
TypeScript
807 lines
34 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 CampaignTimingPanel from "../components/CampaignTimingPanel";
|
|
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",
|
|
disputed: "bg-amber-900/50 text-amber-400 border-amber-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);
|
|
// Start-date confirmation modal — shown when campaign has a future start_date
|
|
const [startDateWarning, setStartDateWarning] = useState<string | null>(null);
|
|
|
|
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<Campaign, unknown, boolean>({
|
|
mutationFn: (force: boolean) => activateCampaign(campaignId!, force ? { force: true } : undefined),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
|
setStartDateWarning(null);
|
|
showToast("Campaign activated", "success");
|
|
},
|
|
onError: (err: unknown) => {
|
|
// 409 = future start_date warning → show confirmation modal instead of toast
|
|
const axiosErr = err as { response?: { status?: number; data?: { message?: string; start_date?: string } } };
|
|
if (axiosErr?.response?.status === 409 && axiosErr.response.data?.message) {
|
|
setStartDateWarning(axiosErr.response.data.message);
|
|
} else {
|
|
showToast((err as Error).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.start_date && (() => {
|
|
const sd = new Date(campaign.start_date);
|
|
const now = new Date();
|
|
const isPast = sd <= now;
|
|
const diffDays = Math.ceil((sd.getTime() - now.getTime()) / 86400000);
|
|
return (
|
|
<span className={`flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${
|
|
isPast
|
|
? "border-green-500/30 bg-green-500/10 text-green-400"
|
|
: "border-amber-500/30 bg-amber-500/10 text-amber-400"
|
|
}`}>
|
|
<Clock className="h-3 w-3" />
|
|
{isPast
|
|
? `Started ${formatDate(campaign.start_date)}`
|
|
: `Starts ${formatDate(campaign.start_date)} (${diffDays}d)`}
|
|
</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(false)}
|
|
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 + Timing side by side */}
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
{/* 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>
|
|
|
|
{/* Campaign Timing */}
|
|
<CampaignTimingPanel campaignId={campaignId!} />
|
|
</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 */}
|
|
<JiraLinkPanel entityType="campaign" entityId={campaignId!} readOnly label={campaign.name} />
|
|
|
|
{/* 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")}
|
|
/>
|
|
|
|
{/* Start-date confirmation modal */}
|
|
{startDateWarning && (
|
|
<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-amber-500/30 bg-gray-900 p-6 shadow-2xl">
|
|
<div className="mb-4 flex items-center gap-3">
|
|
<div className="rounded-lg bg-amber-500/10 p-2">
|
|
<Clock className="h-5 w-5 text-amber-400" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-white">Campaign not started yet</h3>
|
|
</div>
|
|
<p className="mb-6 text-sm text-gray-300 leading-relaxed">{startDateWarning}</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setStartDateWarning(null)}
|
|
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
|
>
|
|
Keep scheduled
|
|
</button>
|
|
<button
|
|
onClick={() => activateMutation.mutate(true)}
|
|
disabled={activateMutation.isPending}
|
|
className="flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors"
|
|
>
|
|
{activateMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
Activate now anyway
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|