feat(threat-actors): Generate Campaign button on actor detail page

Adds a Generate Campaign button (purple, visible to leads/admin) in the
threat actor header. Opens a modal with:
- Actor name shown as context
- Start date picker (required — validated: must be today or future)
- Warning message showing when tests will be queued
- Error display for API failures
- On success: redirects to the new campaign detail page

Start date is mandatory here (unlike the CampaignsPage flow where it
is optional) to enforce scheduling discipline when generating from actors.
This commit is contained in:
kitos
2026-06-04 15:45:55 +02:00
parent b5f924abe0
commit 840e1ac0bb
+175 -13
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import MarkdownText from "../components/MarkdownText"; import MarkdownText from "../components/MarkdownText";
import MotivationBadge from "../components/MotivationBadge"; import MotivationBadge from "../components/MotivationBadge";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Loader2, Loader2,
AlertCircle, AlertCircle,
@@ -17,6 +18,9 @@ import {
Clock, Clock,
Crosshair, Crosshair,
FlaskConical, FlaskConical,
Swords,
X,
Calendar,
} from "lucide-react"; } from "lucide-react";
import { import {
getThreatActor, getThreatActor,
@@ -25,6 +29,8 @@ import {
type ThreatActorTechnique, type ThreatActorTechnique,
type GapItem, type GapItem,
} from "../api/threat-actors"; } from "../api/threat-actors";
import { generateCampaignFromThreatActor } from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
// ── MITRE ATT&CK Tactics in kill chain order ────────────────────── // ── MITRE ATT&CK Tactics in kill chain order ──────────────────────
const TACTICS_ORDER = [ const TACTICS_ORDER = [
@@ -97,6 +103,44 @@ function statusIcon(status: string | null) {
export default function ThreatActorDetailPage() { export default function ThreatActorDetailPage() {
const { actorId } = useParams(); const { actorId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const canGenerate =
user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
// ── Campaign generation modal state ─────────────────────────────
const [showCampaignModal, setShowCampaignModal] = useState(false);
const [campaignStartDate, setCampaignStartDate] = useState("");
const [dateError, setDateError] = useState("");
const generateMutation = useMutation({
mutationFn: () =>
generateCampaignFromThreatActor(actorId!, { start_date: campaignStartDate }),
onSuccess: (campaign) => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
setShowCampaignModal(false);
setCampaignStartDate("");
setDateError("");
navigate(`/campaigns/${campaign.id}`);
},
});
const handleGenerateSubmit = () => {
if (!campaignStartDate) {
setDateError("Start date is required");
return;
}
const selected = new Date(campaignStartDate + "T00:00:00");
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selected < today) {
setDateError("Start date must be today or in the future");
return;
}
setDateError("");
generateMutation.mutate();
};
// ── Queries ───────────────────────────────────────────────────── // ── Queries ─────────────────────────────────────────────────────
const { data: actor, isLoading, error } = useQuery({ const { data: actor, isLoading, error } = useQuery({
@@ -196,18 +240,34 @@ export default function ThreatActorDetailPage() {
)} )}
</div> </div>
{/* MITRE link */} {/* Action buttons */}
{actor.mitre_url && ( <div className="flex items-center gap-2">
<a {canGenerate && (
href={actor.mitre_url} <button
target="_blank" onClick={() => {
rel="noreferrer" setShowCampaignModal(true);
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white transition-colors" setCampaignStartDate("");
> setDateError("");
<ExternalLink className="h-3.5 w-3.5" /> generateMutation.reset();
MITRE ATT&CK }}
</a> className="flex items-center gap-1.5 rounded-lg border border-purple-500/40 bg-purple-900/30 px-3 py-2 text-xs font-medium text-purple-300 hover:bg-purple-900/50 transition-colors"
)} >
<Swords className="h-3.5 w-3.5" />
Generate Campaign
</button>
)}
{actor.mitre_url && (
<a
href={actor.mitre_url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
MITRE ATT&CK
</a>
)}
</div>
</div> </div>
{/* Meta badges */} {/* Meta badges */}
@@ -563,6 +623,108 @@ export default function ThreatActorDetailPage() {
</ul> </ul>
</div> </div>
)} )}
{/* ── Generate Campaign Modal ─────────────────────────────────── */}
{showCampaignModal && (
<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-purple-500/30 bg-gray-900 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
<div className="flex items-center gap-2">
<Swords className="h-5 w-5 text-purple-400" />
<h3 className="text-lg font-semibold text-white">Generate Campaign</h3>
</div>
<button
onClick={() => setShowCampaignModal(false)}
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-5 space-y-5">
{/* Actor info */}
<div className="flex items-center gap-3 rounded-lg border border-purple-500/20 bg-purple-900/10 px-4 py-3">
<Crosshair className="h-4 w-4 shrink-0 text-purple-400" />
<div>
<p className="text-xs text-gray-500">Threat actor</p>
<p className="text-sm font-medium text-white">{actor.name}</p>
</div>
</div>
{/* What this does */}
<p className="text-xs text-gray-400 leading-relaxed">
A campaign will be auto-generated covering all uncovered techniques for this actor,
ordered by kill chain phase. Tests will be hidden from the team queue until the start date.
</p>
{/* Start date — required */}
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-gray-300">
<Calendar className="h-4 w-4 text-purple-400" />
Start date
<span className="text-red-400">*</span>
</label>
<input
type="date"
value={campaignStartDate}
min={new Date().toISOString().split("T")[0]}
onChange={(e) => {
setCampaignStartDate(e.target.value);
if (dateError) setDateError("");
}}
className={`w-full rounded-lg border px-3 py-2 text-sm text-gray-200 bg-gray-800 focus:outline-none focus:border-purple-500 transition-colors [color-scheme:dark] ${
dateError ? "border-red-500" : "border-gray-700"
}`}
/>
{dateError && (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{dateError}
</p>
)}
{campaignStartDate && !dateError && (
<p className="mt-1.5 text-xs text-amber-400">
Tests will be queued for the team on{" "}
{new Date(campaignStartDate + "T00:00:00").toLocaleDateString("en-GB", {
day: "numeric", month: "long", year: "numeric",
})}
</p>
)}
</div>
{/* API error */}
{generateMutation.isError && (
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2.5 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
{(generateMutation.error as Error)?.message || "Failed to generate campaign"}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 border-t border-gray-800 px-6 py-4">
<button
onClick={() => setShowCampaignModal(false)}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleGenerateSubmit}
disabled={generateMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-purple-700 px-5 py-2 text-sm font-medium text-white hover:bg-purple-600 disabled:opacity-50 transition-colors"
>
{generateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Swords className="h-4 w-4" />
)}
Generate Campaign
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }