diff --git a/frontend/src/components/CampaignAuditTimeline.tsx b/frontend/src/components/CampaignAuditTimeline.tsx new file mode 100644 index 0000000..3faf17e --- /dev/null +++ b/frontend/src/components/CampaignAuditTimeline.tsx @@ -0,0 +1,67 @@ +import { Clock, Loader2 } from "lucide-react"; +import type { CampaignTimelineEntry } from "../api/campaigns"; + +interface Props { + entries: CampaignTimelineEntry[]; + isLoading: boolean; +} + +const ACTION_LABELS: Record = { + create_campaign: "Campaign created", + update_campaign: "Campaign updated", + submit_campaign_for_approval: "Submitted for approval", + approve_campaign: "Approved by manager", + reject_campaign: "Rejected by manager", + activate_campaign: "Activated", + complete_campaign: "Marked completed", + request_campaign_modification: "Modification requested", + approve_campaign_modification: "Modification approved", + reject_campaign_modification: "Modification rejected", +}; + +export default function CampaignAuditTimeline({ entries, isLoading }: Props) { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (entries.length === 0) { + return ( +
+ +

No timeline events yet

+
+ ); + } + + const formatDate = (d: string) => + new Date(d).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + return ( +
+
+ {entries.map((entry, idx) => ( +
+
+ +
+
+

+ {ACTION_LABELS[entry.action] || entry.action} +

+

{formatDate(entry.timestamp)}

+
+
+ ))} +
+ ); +} diff --git a/frontend/src/pages/CampaignDetailPage.tsx b/frontend/src/pages/CampaignDetailPage.tsx index 1cf6e83..269f70b 100644 --- a/frontend/src/pages/CampaignDetailPage.tsx +++ b/frontend/src/pages/CampaignDetailPage.tsx @@ -30,11 +30,13 @@ import { listCampaignModificationRequests, approveModificationRequest, rejectModificationRequest, + getCampaignTimeline, type Campaign, type CampaignHistoryEntry, } from "../api/campaigns"; import { useAuth } from "../context/AuthContext"; import CampaignTimeline from "../components/CampaignTimeline"; +import CampaignAuditTimeline from "../components/CampaignAuditTimeline"; import JiraLinkPanel from "../components/JiraLinkPanel"; import CampaignTimingPanel from "../components/CampaignTimingPanel"; import AddTestToCampaignModal from "../components/AddTestToCampaignModal"; @@ -104,6 +106,7 @@ export default function CampaignDetailPage() { mutationFn: () => submitCampaignForApproval(campaignId!), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); + queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] }); showToast("Campaign submitted for approval", "success"); }, onError: (err: Error) => showToast(err.message, "error"), @@ -113,6 +116,7 @@ export default function CampaignDetailPage() { mutationFn: () => approveCampaign(campaignId!, approveStartDate), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); + queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] }); setApproveModalOpen(false); setApproveStartDate(""); showToast("Campaign approved and activated", "success"); @@ -124,6 +128,7 @@ export default function CampaignDetailPage() { mutationFn: () => rejectCampaign(campaignId!, rejectReason), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); + queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] }); setRejectModalOpen(false); setRejectReason(""); showToast("Campaign rejected", "success"); @@ -183,6 +188,12 @@ export default function CampaignDetailPage() { enabled: !!campaignId, }); + const { data: timeline = [], isLoading: isTimelineLoading } = useQuery({ + queryKey: ["campaign-timeline", campaignId], + queryFn: () => getCampaignTimeline(campaignId!), + enabled: !!campaignId, + }); + const approveModRequestMutation = useMutation({ mutationFn: (requestId: string) => approveModificationRequest(requestId), onSuccess: () => { @@ -802,6 +813,12 @@ export default function CampaignDetailPage() {
)} + {/* Timeline */} +
+

Timeline

+ +
+ {/* Jira */} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 7ed07cc..f381d3b 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1217,6 +1217,7 @@ const AZURE_ROLES = [ { value: "admin", label: "Aegis Admin", desc: "Full platform access including system settings" }, { value: "red_lead", label: "Aegis Red Lead", desc: "Red team lead — manage tests, campaigns and templates" }, { value: "blue_lead", label: "Aegis Blue Lead", desc: "Blue team lead — validate tests and manage coverage" }, + { value: "manager", label: "Aegis Manager", desc: "Approves campaigns and campaign changes" }, { value: "red_tech", label: "Aegis Red Tech", desc: "Red team technician — execute tests" }, { value: "blue_tech", label: "Aegis Blue Tech", desc: "Blue team technician — review detections" }, { value: "viewer", label: "Aegis Viewer", desc: "Read-only access to dashboards and reports" }, diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 7897c9e..fc0f486 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -20,6 +20,7 @@ const ROLES = [ { value: "blue_tech", label: "Blue Tech" }, { value: "red_lead", label: "Red Lead" }, { value: "blue_lead", label: "Blue Lead" }, + { value: "manager", label: "Manager" }, { value: "admin", label: "Admin" }, ]; @@ -29,6 +30,7 @@ const roleBadgeColors: Record = { blue_tech: "bg-blue-900/50 text-blue-400 border-blue-500/30", red_lead: "bg-orange-900/50 text-orange-400 border-orange-500/30", blue_lead: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30", + manager: "bg-amber-900/50 text-amber-400 border-amber-500/30", viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30", };