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:
parent
97804f2e5b
commit
74e43e23c4
2 changed files with 94 additions and 1 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue