feat(campaigns): submit/approve/reject UI on campaign detail page
This commit is contained in:
@@ -19,12 +19,14 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getCampaign,
|
getCampaign,
|
||||||
activateCampaign,
|
|
||||||
completeCampaign,
|
completeCampaign,
|
||||||
deleteCampaign,
|
deleteCampaign,
|
||||||
removeTestFromCampaign,
|
removeTestFromCampaign,
|
||||||
scheduleCampaign,
|
scheduleCampaign,
|
||||||
getCampaignHistory,
|
getCampaignHistory,
|
||||||
|
submitCampaignForApproval,
|
||||||
|
approveCampaign,
|
||||||
|
rejectCampaign,
|
||||||
type Campaign,
|
type Campaign,
|
||||||
type CampaignHistoryEntry,
|
type CampaignHistoryEntry,
|
||||||
} from "../api/campaigns";
|
} from "../api/campaigns";
|
||||||
@@ -68,8 +70,10 @@ export default function CampaignDetailPage() {
|
|||||||
const [showAddTestModal, setShowAddTestModal] = useState(false);
|
const [showAddTestModal, setShowAddTestModal] = useState(false);
|
||||||
// 0 = hidden, 1 = first confirmation, 2 = ask about tests
|
// 0 = hidden, 1 = first confirmation, 2 = ask about tests
|
||||||
const [deleteStep, setDeleteStep] = useState<0 | 1 | 2>(0);
|
const [deleteStep, setDeleteStep] = useState<0 | 1 | 2>(0);
|
||||||
// Start-date confirmation modal — shown when campaign has a future start_date
|
const [approveModalOpen, setApproveModalOpen] = useState(false);
|
||||||
const [startDateWarning, setStartDateWarning] = useState<string | null>(null);
|
const [approveStartDate, setApproveStartDate] = useState("");
|
||||||
|
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
|
||||||
const showToast = (message: string, type: "success" | "error") => {
|
const showToast = (message: string, type: "success" | "error") => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
@@ -79,6 +83,7 @@ export default function CampaignDetailPage() {
|
|||||||
const role = user?.role ?? "";
|
const role = user?.role ?? "";
|
||||||
const canManage = role === "admin" || role === "red_lead" || role === "blue_lead";
|
const canManage = role === "admin" || role === "red_lead" || role === "blue_lead";
|
||||||
const canComplete = role === "admin" || role === "red_lead";
|
const canComplete = role === "admin" || role === "red_lead";
|
||||||
|
const isManager = role === "admin" || role === "manager";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: campaign,
|
data: campaign,
|
||||||
@@ -90,33 +95,35 @@ export default function CampaignDetailPage() {
|
|||||||
enabled: !!campaignId,
|
enabled: !!campaignId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activateMutation = useMutation<Campaign, unknown, boolean>({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (force: boolean) => activateCampaign(campaignId!, force ? { force: true } : undefined),
|
mutationFn: () => submitCampaignForApproval(campaignId!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||||||
setStartDateWarning(null);
|
showToast("Campaign submitted for approval", "success");
|
||||||
showToast("Campaign activated", "success");
|
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: Error) => showToast(err.message, "error"),
|
||||||
// The Axios interceptor (client.ts) transforms errors into enhanced Error objects
|
});
|
||||||
// with .status (HTTP status), .detail (raw FastAPI detail), and .message (readable string)
|
|
||||||
type EnhancedError = Error & {
|
|
||||||
status?: number;
|
|
||||||
detail?: { code?: string; message?: string } | string;
|
|
||||||
};
|
|
||||||
const e = err as EnhancedError;
|
|
||||||
|
|
||||||
if (e.status === 409) {
|
const approveMutation = useMutation({
|
||||||
// Future start_date — show confirmation modal using the message from detail
|
mutationFn: () => approveCampaign(campaignId!, approveStartDate),
|
||||||
const warningMsg =
|
onSuccess: () => {
|
||||||
typeof e.detail === "object" && e.detail?.message
|
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||||||
? e.detail.message
|
setApproveModalOpen(false);
|
||||||
: e.message;
|
setApproveStartDate("");
|
||||||
setStartDateWarning(warningMsg);
|
showToast("Campaign approved and activated", "success");
|
||||||
} else {
|
|
||||||
showToast(e.message || "Failed to activate campaign", "error");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
onError: (err: Error) => showToast(err.message, "error"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMutation = useMutation({
|
||||||
|
mutationFn: () => rejectCampaign(campaignId!, rejectReason),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||||||
|
setRejectModalOpen(false);
|
||||||
|
setRejectReason("");
|
||||||
|
showToast("Campaign rejected", "success");
|
||||||
|
},
|
||||||
|
onError: (err: Error) => showToast(err.message, "error"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeMutation = useMutation({
|
const completeMutation = useMutation({
|
||||||
@@ -262,6 +269,11 @@ export default function CampaignDetailPage() {
|
|||||||
{campaign.description && (
|
{campaign.description && (
|
||||||
<MarkdownText content={campaign.description} className="mt-1 text-sm text-gray-400" />
|
<MarkdownText content={campaign.description} className="mt-1 text-sm text-gray-400" />
|
||||||
)}
|
)}
|
||||||
|
{campaign.status === "draft" && campaign.rejection_reason && (
|
||||||
|
<div className="mt-2 rounded-lg border border-amber-500/30 bg-amber-900/20 px-3 py-2 text-xs text-amber-400">
|
||||||
|
Rejected by manager: {campaign.rejection_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
|
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
|
||||||
{campaign.threat_actor_name && (
|
{campaign.threat_actor_name && (
|
||||||
<button
|
<button
|
||||||
@@ -319,18 +331,35 @@ export default function CampaignDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{canManage && campaign.status === "draft" && (
|
{canManage && campaign.status === "draft" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => activateMutation.mutate(false)}
|
onClick={() => submitMutation.mutate()}
|
||||||
disabled={activateMutation.isPending}
|
disabled={submitMutation.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"
|
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 ? (
|
{submitMutation.isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Play className="h-4 w-4" />
|
<Play className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Activate
|
Submit for Approval
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isManager && campaign.status === "pending_approval" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setRejectModalOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 transition-colors"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setApproveModalOpen(true)}
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{canComplete && campaign.status === "active" && (
|
{canComplete && campaign.status === "active" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => completeMutation.mutate()}
|
onClick={() => completeMutation.mutate()}
|
||||||
@@ -688,31 +717,69 @@ export default function CampaignDetailPage() {
|
|||||||
onSuccess={() => showToast("Test added to campaign", "success")}
|
onSuccess={() => showToast("Test added to campaign", "success")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Start-date confirmation modal */}
|
{/* Approve modal */}
|
||||||
{startDateWarning && (
|
{approveModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
<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="mx-4 w-full max-w-md rounded-xl border border-green-500/30 bg-gray-900 p-6 shadow-2xl">
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<h3 className="mb-4 text-lg font-semibold text-white">Approve Campaign</h3>
|
||||||
<div className="rounded-lg bg-amber-500/10 p-2">
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
<Clock className="h-5 w-5 text-amber-400" />
|
Start date <span className="text-red-400">*</span>
|
||||||
</div>
|
</label>
|
||||||
<h3 className="text-lg font-semibold text-white">Campaign not started yet</h3>
|
<input
|
||||||
</div>
|
type="datetime-local"
|
||||||
<p className="mb-6 text-sm text-gray-300 leading-relaxed">{startDateWarning}</p>
|
value={approveStartDate}
|
||||||
<div className="flex justify-end gap-3">
|
onChange={(e) => setApproveStartDate(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStartDateWarning(null)}
|
onClick={() => setApproveModalOpen(false)}
|
||||||
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
Keep scheduled
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => activateMutation.mutate(true)}
|
onClick={() => approveMutation.mutate()}
|
||||||
disabled={activateMutation.isPending}
|
disabled={!approveStartDate || approveMutation.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"
|
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"
|
||||||
>
|
>
|
||||||
{activateMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
{approveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Activate now anyway
|
Approve & Activate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reject modal */}
|
||||||
|
{rejectModalOpen && (
|
||||||
|
<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">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold text-white">Reject Campaign</h3>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Reason <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Explain what needs to change before this can be approved…"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setRejectModalOpen(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={() => rejectMutation.mutate()}
|
||||||
|
disabled={!rejectReason.trim() || rejectMutation.isPending}
|
||||||
|
className="flex items-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"
|
||||||
|
>
|
||||||
|
{rejectMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Reject
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user