Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Add must_change_password field to User model with migration b023 - Add POST /auth/change-password endpoint with password policy validation - Add require_password_changed dependency to block requests until password is changed - Add ChangePasswordModal with live password policy checklist (forced on first login) - Show password policy in CreateUserModal and EditUserModal - Fix backend permissions: tests, campaigns, templates, reports, evidence, worklogs - red_tech/blue_tech: execute only, cannot create tests/campaigns/templates - red_lead/blue_lead: create/edit tests/campaigns/templates, generate reports, no system access - viewer: read-only everywhere, can generate reports - Fix frontend role checks across TestDetailPage, TestDetailHeader, TeamTabs, TestsPage, CampaignsPage, CampaignDetailPage, Sidebar
456 lines
19 KiB
TypeScript
456 lines
19 KiB
TypeScript
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Loader2,
|
|
AlertCircle,
|
|
Plus,
|
|
Search,
|
|
Crosshair,
|
|
Zap,
|
|
Filter,
|
|
Target,
|
|
} from "lucide-react";
|
|
import { listCampaigns, createCampaign, generateCampaignFromThreatActor, type CampaignSummary } from "../api/campaigns";
|
|
import { getThreatActors, type ThreatActorSummary } from "../api/threat-actors";
|
|
import { useAuth } from "../context/AuthContext";
|
|
|
|
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 typeColors: Record<string, string> = {
|
|
custom: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
|
apt_emulation: "bg-red-900/50 text-red-400 border-red-500/30",
|
|
kill_chain: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
|
compliance: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
|
};
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
custom: "Custom",
|
|
apt_emulation: "APT Emulation",
|
|
kill_chain: "Kill Chain",
|
|
compliance: "Compliance",
|
|
};
|
|
|
|
export default function CampaignsPage() {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { user } = useAuth();
|
|
|
|
const [filters, setFilters] = useState({
|
|
type: "",
|
|
status: "",
|
|
search: "",
|
|
});
|
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
const [showActorSelector, setShowActorSelector] = useState(false);
|
|
const [actorSearch, setActorSearch] = useState("");
|
|
const [newCampaign, setNewCampaign] = useState({
|
|
name: "",
|
|
description: "",
|
|
type: "custom",
|
|
target_platform: "",
|
|
});
|
|
|
|
const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["campaigns", filters],
|
|
queryFn: () =>
|
|
listCampaigns({
|
|
type: filters.type || undefined,
|
|
status: filters.status || undefined,
|
|
search: filters.search || undefined,
|
|
}),
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: () => createCampaign(newCampaign),
|
|
onSuccess: (campaign) => {
|
|
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
|
setShowCreateForm(false);
|
|
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
|
|
navigate(`/campaigns/${campaign.id}`);
|
|
},
|
|
});
|
|
|
|
// Threat actor selector data
|
|
const { data: actorsData, isLoading: isLoadingActors } = useQuery({
|
|
queryKey: ["threat-actors-for-campaign", actorSearch],
|
|
queryFn: () => getThreatActors({ search: actorSearch || undefined, limit: 50 }),
|
|
enabled: showActorSelector,
|
|
});
|
|
|
|
const generateMutation = useMutation({
|
|
mutationFn: (actorId: string) => generateCampaignFromThreatActor(actorId),
|
|
onSuccess: (campaign) => {
|
|
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
|
setShowActorSelector(false);
|
|
navigate(`/campaigns/${campaign.id}`);
|
|
},
|
|
});
|
|
|
|
const formatDate = (dateStr: string | null) => {
|
|
if (!dateStr) return "";
|
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Campaigns</h1>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
Manage attack chain campaigns and APT emulations
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{canCreate && (
|
|
<>
|
|
<button
|
|
onClick={() => setShowActorSelector(true)}
|
|
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-500/20 transition-colors"
|
|
>
|
|
<Crosshair className="h-4 w-4" />
|
|
Generate from Threat Actor
|
|
</button>
|
|
<button
|
|
onClick={() => setShowCreateForm(true)}
|
|
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 transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
New Campaign
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
|
<input
|
|
value={filters.search}
|
|
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
|
|
placeholder="Search campaigns..."
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-10 pr-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={filters.type}
|
|
onChange={(e) => setFilters((f) => ({ ...f, type: e.target.value }))}
|
|
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="custom">Custom</option>
|
|
<option value="apt_emulation">APT Emulation</option>
|
|
<option value="kill_chain">Kill Chain</option>
|
|
<option value="compliance">Compliance</option>
|
|
</select>
|
|
|
|
<select
|
|
value={filters.status}
|
|
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value }))}
|
|
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="draft">Draft</option>
|
|
<option value="active">Active</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Create Form Modal */}
|
|
{showCreateForm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<h2 className="mb-4 text-lg font-semibold text-white">Create Campaign</h2>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Name</label>
|
|
<input
|
|
value={newCampaign.name}
|
|
onChange={(e) => setNewCampaign((c) => ({ ...c, name: e.target.value }))}
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
|
placeholder="Campaign name..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Description</label>
|
|
<textarea
|
|
value={newCampaign.description}
|
|
onChange={(e) => setNewCampaign((c) => ({ ...c, description: e.target.value }))}
|
|
rows={3}
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
|
placeholder="Describe the campaign objective..."
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Type</label>
|
|
<select
|
|
value={newCampaign.type}
|
|
onChange={(e) => setNewCampaign((c) => ({ ...c, type: e.target.value }))}
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
|
>
|
|
<option value="custom">Custom</option>
|
|
<option value="kill_chain">Kill Chain</option>
|
|
<option value="compliance">Compliance</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Platform</label>
|
|
<select
|
|
value={newCampaign.target_platform}
|
|
onChange={(e) => setNewCampaign((c) => ({ ...c, target_platform: e.target.value }))}
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
|
>
|
|
<option value="">Any</option>
|
|
<option value="windows">Windows</option>
|
|
<option value="linux">Linux</option>
|
|
<option value="macos">macOS</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setShowCreateForm(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={() => createMutation.mutate()}
|
|
disabled={!newCampaign.name || createMutation.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"
|
|
>
|
|
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
Create Campaign
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Threat Actor Selector Modal */}
|
|
{showActorSelector && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 p-6">
|
|
<h2 className="mb-1 text-lg font-semibold text-white">Generate Campaign from Threat Actor</h2>
|
|
<p className="mb-4 text-sm text-gray-400">
|
|
Select a threat actor to auto-generate a campaign with tests for their uncovered techniques.
|
|
</p>
|
|
|
|
{/* Search */}
|
|
<div className="relative mb-4">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
|
<input
|
|
value={actorSearch}
|
|
onChange={(e) => setActorSearch(e.target.value)}
|
|
placeholder="Search threat actors..."
|
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-10 pr-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Actor list */}
|
|
<div className="max-h-72 overflow-y-auto rounded-lg border border-gray-800">
|
|
{isLoadingActors ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : actorsData && actorsData.items.length > 0 ? (
|
|
actorsData.items.map((actor) => (
|
|
<button
|
|
key={actor.id}
|
|
onClick={() => generateMutation.mutate(actor.id)}
|
|
disabled={generateMutation.isPending}
|
|
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-gray-800/50 transition-colors border-b border-gray-800/50 last:border-b-0 disabled:opacity-50"
|
|
>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Crosshair className="h-3.5 w-3.5 text-red-400" />
|
|
<span className="text-sm font-medium text-white">{actor.name}</span>
|
|
{actor.country && (
|
|
<span className="text-xs text-gray-500">{actor.country}</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
|
|
<span>{actor.technique_count} techniques</span>
|
|
<span>Coverage: {actor.coverage_pct}%</span>
|
|
</div>
|
|
</div>
|
|
<Target className="h-4 w-4 text-gray-600" />
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="py-8 text-center text-sm text-gray-500">
|
|
No threat actors found
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{generateMutation.isError && (
|
|
<div className="mt-3 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2 text-xs text-red-400">
|
|
<AlertCircle className="h-3.5 w-3.5" />
|
|
{(generateMutation.error as Error)?.message || "Failed to generate campaign"}
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading indicator */}
|
|
{generateMutation.isPending && (
|
|
<div className="mt-3 flex items-center gap-2 text-sm text-cyan-400">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Generating campaign...
|
|
</div>
|
|
)}
|
|
|
|
{/* Cancel */}
|
|
<div className="mt-4 flex justify-end">
|
|
<button
|
|
onClick={() => { setShowActorSelector(false); setActorSearch(""); }}
|
|
disabled={generateMutation.isPending}
|
|
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Campaign grid */}
|
|
{isLoading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : error ? (
|
|
<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 campaigns</p>
|
|
</div>
|
|
) : data && data.items.length > 0 ? (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{data.items.map((campaign) => (
|
|
<button
|
|
key={campaign.id}
|
|
onClick={() => navigate(`/campaigns/${campaign.id}`)}
|
|
className="group rounded-xl border border-gray-800 bg-gray-900 p-5 text-left transition-all hover:border-gray-700 hover:shadow-lg"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
|
typeColors[campaign.type] || typeColors.custom
|
|
}`}
|
|
>
|
|
{typeLabels[campaign.type] || campaign.type}
|
|
</span>
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
|
statusColors[campaign.status] || statusColors.draft
|
|
}`}
|
|
>
|
|
{campaign.status}
|
|
</span>
|
|
</div>
|
|
<Zap className="h-4 w-4 text-gray-600 group-hover:text-cyan-400 transition-colors" />
|
|
</div>
|
|
|
|
{/* Name & Description */}
|
|
<h3 className="text-sm font-semibold text-white group-hover:text-cyan-300 transition-colors truncate">
|
|
{campaign.name}
|
|
</h3>
|
|
{campaign.description && (
|
|
<p className="mt-1 text-xs text-gray-400 line-clamp-2">
|
|
{campaign.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* Threat Actor */}
|
|
{campaign.threat_actor_name && (
|
|
<div className="mt-2 flex items-center gap-1.5">
|
|
<Target className="h-3.5 w-3.5 text-red-400" />
|
|
<span className="text-xs text-red-400">{campaign.threat_actor_name}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress bar */}
|
|
<div className="mt-3">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-[10px] text-gray-500">
|
|
{campaign.test_count} test{campaign.test_count !== 1 ? "s" : ""}
|
|
</span>
|
|
<span className="text-[10px] font-medium text-gray-400">
|
|
{campaign.completion_pct}%
|
|
</span>
|
|
</div>
|
|
<div className="h-1.5 w-full rounded-full bg-gray-800 overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
campaign.completion_pct === 100
|
|
? "bg-green-500"
|
|
: campaign.completion_pct > 0
|
|
? "bg-cyan-500"
|
|
: "bg-gray-700"
|
|
}`}
|
|
style={{ width: `${campaign.completion_pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{campaign.tags && campaign.tags.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{campaign.tags.slice(0, 3).map((tag, i) => (
|
|
<span
|
|
key={i}
|
|
className="rounded-full bg-gray-800 border border-gray-700 px-1.5 py-0.5 text-[10px] text-gray-400"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{campaign.tags.length > 3 && (
|
|
<span className="text-[10px] text-gray-500">+{campaign.tags.length - 3}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Date */}
|
|
<p className="mt-2 text-[10px] text-gray-600">{formatDate(campaign.created_at)}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
|
<Zap className="h-10 w-10 text-gray-600" />
|
|
<p className="text-gray-400">No campaigns found</p>
|
|
{canCreate && (
|
|
<button
|
|
onClick={() => setShowCreateForm(true)}
|
|
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Create your first campaign
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|