feat(260717-v6c): add Mark as accidental report button, dialog, and timeline case

- ActionAreaCard: new GatedButton + confirm AlertDialog (optional reason),
  resolved/tooltip logic now covers accidental_report status, toast
  distinguishes full success from note-post failure
- TimelineCard: campaign_marked_accidental_report entry uses the
  blue/CheckCircle2 tint (distinct from slate/XCircle false-positive)
This commit is contained in:
lorentz 2026-07-17 22:34:09 -04:00
parent 97804f2e5b
commit 74e43e23c4
2 changed files with 94 additions and 1 deletions

View file

@ -313,9 +313,12 @@ export function ActionAreaCard({
const [isApproving, setIsApproving] = useState(false);
const [isRemediating, setIsRemediating] = useState(false);
const [isMarkingFalsePositive, setIsMarkingFalsePositive] = useState(false);
const [isMarkingAccidentalReport, setIsMarkingAccidentalReport] = useState(false);
const [remediateDialogOpen, setRemediateDialogOpen] = useState(false);
const [falsePositiveDialogOpen, setFalsePositiveDialogOpen] = useState(false);
const [falsePositiveReason, setFalsePositiveReason] = useState('');
const [accidentalReportDialogOpen, setAccidentalReportDialogOpen] = useState(false);
const [accidentalReportReason, setAccidentalReportReason] = useState('');
const recommendedActionsKey = classification?.recommendedActions.join(',') ?? '';
@ -409,7 +412,8 @@ export function ActionAreaCard({
// remediation. Once resolved, all three buttons stay in the DOM but are
// disabled with a resolved-state tooltip.
const completedAction = remediationActions.find((a) => a.status === 'completed');
const resolved = campaignStatus === 'false_positive' || completedAction != null;
const resolved =
campaignStatus === 'false_positive' || campaignStatus === 'accidental_report' || completedAction != null;
function resolvedTooltipCopy(): string {
if (completedAction) {
@ -418,6 +422,9 @@ export function ActionAreaCard({
: 'an earlier date';
return `Already remediated on ${dateStr} by ${completedAction.approvedBy ?? 'unknown'}`;
}
if (campaignStatus === 'accidental_report') {
return `Marked as an accidental report on ${new Date(campaignUpdatedAt).toLocaleDateString()}`;
}
return `Marked as false positive on ${new Date(campaignUpdatedAt).toLocaleDateString()}`;
}
@ -455,6 +462,14 @@ export function ActionAreaCard({
? 'Cannot mark false positive — this campaign already has approved or completed remediation'
: null;
const markAccidentalReportDisabledReason = !canApprove
? 'Requires approve permission'
: resolved
? resolvedTooltipCopy()
: hasBlockingRemediation
? 'Cannot mark as accidental report — this campaign already has approved or completed remediation'
: null;
async function handleRemediateConfirm() {
setIsRemediating(true);
try {
@ -493,6 +508,35 @@ export function ActionAreaCard({
}
}
async function handleMarkAccidentalReportConfirm() {
setIsMarkingAccidentalReport(true);
try {
const res = await fetch(`/api/phishing/campaigns/${campaignId}/mark-accidental-report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(accidentalReportReason ? { reason: accidentalReportReason } : {}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Mark as accidental report failed');
if (data.notePosted === false) {
toast.warning(
`Campaign marked as accidental report, but the reporter note failed to post${
data.noteError ? `: ${data.noteError}` : ''
} follow up manually.`
);
} else {
toast.success('Marked as accidental report, reporter notified');
}
setAccidentalReportDialogOpen(false);
setAccidentalReportReason('');
onActionComplete();
} catch (err) {
toast.error(`Mark as accidental report failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsMarkingAccidentalReport(false);
}
}
return (
<Card>
<CardHeader>
@ -556,6 +600,16 @@ export function ActionAreaCard({
>
Mark as false positive
</GatedButton>
<GatedButton
disabled={!!markAccidentalReportDisabledReason}
reason={markAccidentalReportDisabledReason}
loading={isMarkingAccidentalReport}
variant="outline"
onClick={() => setAccidentalReportDialogOpen(true)}
>
Mark as accidental report
</GatedButton>
</div>
</TooltipProvider>
@ -617,6 +671,38 @@ export function ActionAreaCard({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={accidentalReportDialogOpen} onOpenChange={setAccidentalReportDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark as an accidental report?</AlertDialogTitle>
<AlertDialogDescription>
Mark this campaign as an accidental report? This posts a note to the reporting employee
explaining no action is needed, and closes out the campaign. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5">
<Label htmlFor="accidental-report-reason">Reason (optional)</Label>
<Textarea
id="accidental-report-reason"
value={accidentalReportReason}
onChange={(e) => setAccidentalReportReason(e.target.value)}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isMarkingAccidentalReport}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isMarkingAccidentalReport}
onClick={(e) => {
e.preventDefault();
void handleMarkAccidentalReportConfirm();
}}
>
Mark as accidental report
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);

View file

@ -94,6 +94,13 @@ function renderEntry(entry: TimelineEntry): {
dotClass: 'bg-slate-500',
textClass: 'text-slate-600',
};
case 'campaign_marked_accidental_report':
return {
label: 'Marked as accidental report — reporter notified',
icon: CheckCircle2,
dotClass: 'bg-blue-500',
textClass: 'text-blue-600',
};
case 'campaign_classified': {
const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS' | undefined;
const tint = (verdict && VERDICT_TINT[verdict]) || 'bg-muted-foreground text-muted-foreground';