wulf-pulse/components/phishing/action-area-card.tsx
lorentz 74e43e23c4 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)
2026-07-17 22:34:09 -04:00

709 lines
25 KiB
TypeScript

'use client';
/**
* ActionAreaCard — the only interactive remediation surface on the phishing
* campaign review page (REVIEW-05, REVIEW-06). Renders one checkbox row per
* recommended action with an always-visible, editable params form
* pre-filled via deriveDefaultParams(), and submits exactly
* ApproveActionInput[] to the existing /approve route.
*
* D-04: every action refetches via onActionComplete() on success — this
* component never optimistically mutates local campaign state.
*/
import { useEffect, useState, type ReactNode } from 'react';
import { Loader2, ShieldAlert } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button, buttonVariants } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { useSession } from '@/lib/auth-client';
import { hasPermission } from '@/lib/permissions';
import { deriveDefaultParams } from '@/lib/services/remediation-default-params';
export interface RemediationActionSummary {
id: string;
actionType: string;
status: string;
completedAt: string | null;
approvedBy: string | null;
}
export interface ActionAreaClassification {
recommendedActions: string[];
}
export interface ActionAreaEvidence {
requesterEmail: string | null;
senderEmail: string | null;
senderDomain: string | null;
messageId: string | null;
}
interface ActionAreaCardProps {
campaignId: string;
classification: ActionAreaClassification | null;
remediationActions: RemediationActionSummary[];
campaignStatus: string;
campaignUpdatedAt: string;
evidence: ActionAreaEvidence;
onActionComplete: () => void;
}
const ACTION_LABEL: Record<string, string> = {
block_sender: 'Block sender',
purge_message: 'Purge message',
warn_user: 'Warn user',
no_action: 'No action',
reset_password: 'Reset password',
isolate_endpoint: 'Isolate endpoint',
disable_forwarding_rule: 'Disable forwarding rule',
acknowledge_user: 'Acknowledge user',
};
function humanizeAction(actionType: string): string {
return (
ACTION_LABEL[actionType] ??
actionType
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
);
}
interface ActionRowState {
checked: boolean;
params: Record<string, unknown>;
}
/**
* Normalizes a row's editable params back into the submit shape. The only
* field that needs conversion is purge_message's `mailboxes` — edited as a
* comma-separated string in the form, submitted as `string[]` to match
* ApproveActionInput exactly.
*/
function normalizeParamsForSubmit(actionType: string, params: Record<string, unknown>): Record<string, unknown> {
if (actionType === 'purge_message' && typeof params.mailboxes === 'string') {
return {
...params,
mailboxes: params.mailboxes
.split(',')
.map((m) => m.trim())
.filter(Boolean),
};
}
return params;
}
function ActionParamsForm({
actionType,
params,
onChange,
}: {
actionType: string;
params: Record<string, unknown>;
onChange: (params: Record<string, unknown>) => void;
}) {
function setField(key: string, value: string) {
onChange({ ...params, [key]: value });
}
const str = (key: string) => (typeof params[key] === 'string' ? (params[key] as string) : '');
switch (actionType) {
case 'no_action':
return (
<p className="text-sm text-muted-foreground">
No parameters informational verdict, no remediation needed.
</p>
);
case 'acknowledge_user':
return (
<p className="text-sm text-muted-foreground">
No parameters posts a customer-visible thank-you note to the reporting employee.
</p>
);
case 'warn_user':
return (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-recipientEmail`}>Recipient email</Label>
<Input
id={`${actionType}-recipientEmail`}
value={str('recipientEmail')}
onChange={(e) => setField('recipientEmail', e.target.value)}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor={`${actionType}-message`}>Message</Label>
<Textarea
id={`${actionType}-message`}
value={str('message')}
onChange={(e) => setField('message', e.target.value)}
/>
</div>
</div>
);
case 'block_sender':
return (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-senderEmail`}>Sender email</Label>
<Input
id={`${actionType}-senderEmail`}
value={str('senderEmail')}
onChange={(e) => setField('senderEmail', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-senderDomain`}>Sender domain</Label>
<Input
id={`${actionType}-senderDomain`}
value={str('senderDomain')}
onChange={(e) => setField('senderDomain', e.target.value)}
/>
</div>
</div>
);
case 'purge_message':
return (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-messageId`}>Message ID</Label>
<Input
id={`${actionType}-messageId`}
value={str('messageId')}
onChange={(e) => setField('messageId', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-mailboxes`}>Mailboxes</Label>
<Input
id={`${actionType}-mailboxes`}
value={str('mailboxes')}
onChange={(e) => setField('mailboxes', e.target.value)}
/>
<p className="text-xs text-muted-foreground">Enter affected mailboxes, comma-separated</p>
</div>
</div>
);
case 'reset_password':
return (
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-userPrincipalName`}>User principal name</Label>
<Input
id={`${actionType}-userPrincipalName`}
value={str('userPrincipalName')}
onChange={(e) => setField('userPrincipalName', e.target.value)}
/>
</div>
);
case 'isolate_endpoint':
return (
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-deviceId`}>Device ID</Label>
<Input
id={`${actionType}-deviceId`}
value={str('deviceId')}
onChange={(e) => setField('deviceId', e.target.value)}
/>
<p className="text-xs text-muted-foreground">
No device identifier available from evidence enter manually
</p>
</div>
);
case 'disable_forwarding_rule':
return (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-userPrincipalName`}>User principal name</Label>
<Input
id={`${actionType}-userPrincipalName`}
value={str('userPrincipalName')}
onChange={(e) => setField('userPrincipalName', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${actionType}-ruleName`}>Rule name</Label>
<Input
id={`${actionType}-ruleName`}
value={str('ruleName')}
onChange={(e) => setField('ruleName', e.target.value)}
/>
</div>
</div>
);
default:
return null;
}
}
/**
* A button that is ALWAYS rendered (D-05 — never removed from the DOM), and
* wrapped in a Tooltip explaining why it's disabled whenever `reason` is
* non-null. Disabled native <button>s don't reliably fire hover events, so
* the Tooltip trigger wraps a focusable <span> around the button rather than
* the button itself.
*/
function GatedButton({
children,
disabled,
reason,
loading,
onClick,
variant,
}: {
children: ReactNode;
disabled: boolean;
reason: string | null;
loading?: boolean;
onClick: () => void;
variant?: 'default' | 'outline' | 'destructive';
}) {
const button = (
<Button variant={variant} disabled={disabled || !!loading} onClick={onClick}>
{loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : null}
{children}
</Button>
);
if (!disabled || !reason) return button;
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} className="inline-block">
{button}
</span>
</TooltipTrigger>
<TooltipContent>{reason}</TooltipContent>
</Tooltip>
);
}
export function ActionAreaCard({
campaignId,
classification,
remediationActions,
campaignStatus,
campaignUpdatedAt,
evidence,
onActionComplete,
}: ActionAreaCardProps) {
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canApprove = hasPermission(role, 'phishing', 'approve');
const canRemediate = hasPermission(role, 'phishing', 'remediate');
const [rows, setRows] = useState<Record<string, ActionRowState>>({});
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(',') ?? '';
useEffect(() => {
if (!classification) {
setRows({});
return;
}
const next: Record<string, ActionRowState> = {};
for (const actionType of classification.recommendedActions) {
const params = deriveDefaultParams(actionType, evidence);
if (actionType === 'purge_message' && Array.isArray(params.mailboxes)) {
params.mailboxes = (params.mailboxes as string[]).join(', ');
}
next[actionType] = { checked: false, params };
}
setRows(next);
// Re-derive only when the recommended-action list itself or the source
// evidence changes — not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
recommendedActionsKey,
evidence.requesterEmail,
evidence.senderEmail,
evidence.senderDomain,
evidence.messageId,
]);
function updateRow(actionType: string, patch: Partial<ActionRowState>) {
setRows((prev) => ({
...prev,
[actionType]: { ...prev[actionType], ...patch },
}));
}
// NULL-GUARD FIRST: classification is Classification | null — the default
// state for a freshly-grouped campaign, since classification is never
// auto-triggered. Never dereference classification.recommendedActions
// before this guard.
if (classification == null || classification.recommendedActions.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="font-bold">
<ShieldAlert className="h-4 w-4 mr-2 inline" />
Action Area
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No classification yet classify this campaign to see recommended remediation actions.
</p>
</CardContent>
</Card>
);
}
// TS can't retain the null-guard's narrowing across the closures below
// (handleApprove captures `classification` from the outer scope) — bind a
// locally-narrowed const instead of asserting `!` at each use site.
const activeClassification = classification;
const checkedCount = Object.values(rows).filter((row) => row.checked).length;
async function handleApprove() {
const actions = activeClassification.recommendedActions
.filter((actionType) => rows[actionType]?.checked)
.map((actionType) => ({
actionType,
params: normalizeParamsForSubmit(actionType, rows[actionType].params),
}));
setIsApproving(true);
try {
const res = await fetch(`/api/phishing/campaigns/${campaignId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actions }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Approve failed');
toast.success(`Approved ${Array.isArray(data) ? data.length : actions.length} remediation action(s)`);
onActionComplete();
} catch (err) {
toast.error(`Approve failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsApproving(false);
}
}
// D-05 "resolved" definition — a false-positive campaign or any completed
// 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' || campaignStatus === 'accidental_report' || completedAction != null;
function resolvedTooltipCopy(): string {
if (completedAction) {
const dateStr = completedAction.completedAt
? new Date(completedAction.completedAt).toLocaleDateString()
: '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()}`;
}
const approvedActions = remediationActions.filter((a) => a.status === 'approved');
const hasBlockingRemediation = remediationActions.some(
(a) => a.status === 'approved' || a.status === 'completed'
);
// Priority-ordered disable reasons — first match wins (UI-SPEC Action Area
// Spec, "Buttons" section). All permission checks use hasPermission() from
// lib/permissions.ts — the exact function the server routes enforce —
// never a bespoke role string check, so client and server can never drift
// (REVIEW-06).
const approveDisabledReason = !canApprove
? 'Requires approve permission'
: resolved
? resolvedTooltipCopy()
: checkedCount === 0
? 'Select at least one action to approve'
: null;
const remediateDisabledReason = !canRemediate
? 'Requires remediate permission'
: resolved
? resolvedTooltipCopy()
: approvedActions.length === 0
? 'No approved actions to remediate'
: null;
const markFalsePositiveDisabledReason = !canApprove
? 'Requires approve permission'
: resolved
? resolvedTooltipCopy()
: hasBlockingRemediation
? '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 {
const res = await fetch(`/api/phishing/campaigns/${campaignId}/remediate`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Remediate failed');
const count = Array.isArray(data.actions) ? data.actions.length : approvedActions.length;
toast.success(`Remediation completed for ${count} action(s)`);
setRemediateDialogOpen(false);
onActionComplete();
} catch (err) {
toast.error(`Remediate failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsRemediating(false);
}
}
async function handleMarkFalsePositiveConfirm() {
setIsMarkingFalsePositive(true);
try {
const res = await fetch(`/api/phishing/campaigns/${campaignId}/mark-false-positive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(falsePositiveReason ? { reason: falsePositiveReason } : {}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Mark as false positive failed');
toast.success('Campaign marked as false positive');
setFalsePositiveDialogOpen(false);
setFalsePositiveReason('');
onActionComplete();
} catch (err) {
toast.error(`Mark as false positive failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsMarkingFalsePositive(false);
}
}
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>
<CardTitle className="font-bold">
<ShieldAlert className="h-4 w-4 mr-2 inline" />
Action Area
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
{classification.recommendedActions.map((actionType) => {
const row = rows[actionType];
if (!row) return null;
return (
<div key={actionType} className="rounded-md border p-3 space-y-3">
<div className="flex items-center gap-2">
<Checkbox
id={`action-${actionType}`}
checked={row.checked}
onCheckedChange={(checked) => updateRow(actionType, { checked: checked === true })}
/>
<Label htmlFor={`action-${actionType}`}>{humanizeAction(actionType)}</Label>
</div>
<ActionParamsForm
actionType={actionType}
params={row.params}
onChange={(params) => updateRow(actionType, { params })}
/>
</div>
);
})}
</div>
<TooltipProvider>
<div className="flex flex-wrap gap-2">
<GatedButton
disabled={!!approveDisabledReason}
reason={approveDisabledReason}
loading={isApproving}
onClick={handleApprove}
>
Approve selected
</GatedButton>
<GatedButton
disabled={!!remediateDisabledReason}
reason={remediateDisabledReason}
loading={isRemediating}
variant="outline"
onClick={() => setRemediateDialogOpen(true)}
>
Remediate approved actions
</GatedButton>
<GatedButton
disabled={!!markFalsePositiveDisabledReason}
reason={markFalsePositiveDisabledReason}
loading={isMarkingFalsePositive}
variant="destructive"
onClick={() => setFalsePositiveDialogOpen(true)}
>
Mark as false positive
</GatedButton>
<GatedButton
disabled={!!markAccidentalReportDisabledReason}
reason={markAccidentalReportDisabledReason}
loading={isMarkingAccidentalReport}
variant="outline"
onClick={() => setAccidentalReportDialogOpen(true)}
>
Mark as accidental report
</GatedButton>
</div>
</TooltipProvider>
<AlertDialog open={remediateDialogOpen} onOpenChange={setRemediateDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remediate approved actions?</AlertDialogTitle>
<AlertDialogDescription>
Remediate {approvedActions.length} approved action(s):{' '}
{approvedActions.map((a) => humanizeAction(a.actionType)).join(', ')}. This executes the
simulated remediation effect and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemediating}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: 'destructive' })}
disabled={isRemediating}
onClick={(e) => {
e.preventDefault();
void handleRemediateConfirm();
}}
>
Remediate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={falsePositiveDialogOpen} onOpenChange={setFalsePositiveDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark as false positive?</AlertDialogTitle>
<AlertDialogDescription>
Mark this campaign as a false positive? This cannot be undone there is no way to reverse it
later.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5">
<Label htmlFor="false-positive-reason">Reason (optional)</Label>
<Textarea
id="false-positive-reason"
value={falsePositiveReason}
onChange={(e) => setFalsePositiveReason(e.target.value)}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isMarkingFalsePositive}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: 'destructive' })}
disabled={isMarkingFalsePositive}
onClick={(e) => {
e.preventDefault();
void handleMarkFalsePositiveConfirm();
}}
>
Mark as false positive
</AlertDialogAction>
</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>
);
}