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:
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import MarkdownText from "../components/MarkdownText";
|
||||
import MotivationBadge from "../components/MotivationBadge";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
@@ -17,6 +18,9 @@ import {
|
||||
Clock,
|
||||
Crosshair,
|
||||
FlaskConical,
|
||||
Swords,
|
||||
X,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getThreatActor,
|
||||
@@ -25,6 +29,8 @@ import {
|
||||
type ThreatActorTechnique,
|
||||
type GapItem,
|
||||
} from "../api/threat-actors";
|
||||
import { generateCampaignFromThreatActor } from "../api/campaigns";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
// ── MITRE ATT&CK Tactics in kill chain order ──────────────────────
|
||||
const TACTICS_ORDER = [
|
||||
@@ -97,6 +103,44 @@ function statusIcon(status: string | null) {
|
||||
export default function ThreatActorDetailPage() {
|
||||
const { actorId } = useParams();
|
||||
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 ─────────────────────────────────────────────────────
|
||||
const { data: actor, isLoading, error } = useQuery({
|
||||
@@ -196,7 +240,22 @@ export default function ThreatActorDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MITRE link */}
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{canGenerate && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCampaignModal(true);
|
||||
setCampaignStartDate("");
|
||||
setDateError("");
|
||||
generateMutation.reset();
|
||||
}}
|
||||
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}
|
||||
@@ -209,6 +268,7 @@ export default function ThreatActorDetailPage() {
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta badges */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
@@ -563,6 +623,108 @@ export default function ThreatActorDetailPage() {
|
||||
</ul>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user