feat(campaigns): audit timeline UI and manager role in role pickers
This commit is contained in:
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<Clock className="mx-auto h-8 w-8 text-gray-600" />
|
||||
<p className="mt-2 text-sm text-gray-400">No timeline events yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (d: string) =>
|
||||
new Date(d).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative space-y-0">
|
||||
<div className="absolute left-4 top-2 bottom-2 w-px bg-gray-700" />
|
||||
{entries.map((entry, idx) => (
|
||||
<div key={entry.id || idx} className="relative flex gap-4 py-3">
|
||||
<div className="z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-700 bg-gray-800">
|
||||
<Clock className="h-3.5 w-3.5 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
{ACTION_LABELS[entry.action] || entry.action}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(entry.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Timeline</h2>
|
||||
<CampaignAuditTimeline entries={timeline} isLoading={isTimelineLoading} />
|
||||
</div>
|
||||
|
||||
{/* Jira */}
|
||||
<JiraLinkPanel entityType="campaign" entityId={campaignId!} readOnly label={campaign.name} />
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user