wulf-pulse/components/phishing/timeline-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

171 lines
6.1 KiB
TypeScript

'use client';
import { FileText, Sparkles, CheckCircle2, ShieldCheck, XCircle, Circle } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
export type TimelineEntry =
| { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }
| {
kind: 'classification';
at: string;
verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS';
confidence: string | null;
}
| { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown };
interface TimelineCardProps {
timeline: TimelineEntry[];
}
const VERDICT_TINT: Record<'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS', string> = {
SPAM: 'bg-slate-500 text-slate-600',
UNWANTED: 'bg-amber-500 text-amber-600',
THREAT: 'bg-destructive text-destructive',
USER_AWARENESS: 'bg-emerald-500 text-emerald-600',
};
function humanizeEventType(eventType: string): string {
return eventType
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function renderEntry(entry: TimelineEntry): {
label: string;
icon: React.ComponentType<{ className?: string }>;
dotClass: string;
textClass: string;
} {
if (entry.kind === 'report') {
const ticketLabel = entry.ticketNumber ? `Ticket #${entry.ticketNumber}` : 'Ticket';
const company = entry.companyName ? ` (${entry.companyName})` : '';
return {
label: `Report linked — ${ticketLabel}${company}`,
icon: FileText,
dotClass: 'bg-muted-foreground',
textClass: 'text-muted-foreground',
};
}
if (entry.kind === 'classification') {
const confidencePct = entry.confidence != null ? Number(entry.confidence) : NaN;
const confidence = Number.isFinite(confidencePct) ? ` (${Math.round(confidencePct * 100)}%)` : '';
// Defensive fallback: phishing-timeline.ts emits verdict as a plain
// unvalidated runtime `string`, so a value outside this component's
// narrowed union must never crash the render (T-23-12).
const tint = VERDICT_TINT[entry.verdict] ?? 'bg-muted-foreground text-muted-foreground';
return {
label: `Classified as ${entry.verdict}${confidence}`,
icon: Sparkles,
dotClass: tint.split(' ')[0],
textClass: tint.split(' ')[1],
};
}
// entry.kind === 'audit'
const payload = (entry.payload ?? {}) as Record<string, unknown>;
switch (entry.eventType) {
case 'remediation_approved': {
const count =
(Array.isArray(payload.actionIds) && payload.actionIds.length) ||
(Array.isArray(payload.actions) && payload.actions.length) ||
0;
return {
label: `${count} action(s) approved by ${entry.actor ?? 'unknown'}`,
icon: CheckCircle2,
dotClass: 'bg-blue-500',
textClass: 'text-blue-600',
};
}
case 'remediation_completed':
return {
label: 'Remediation completed',
icon: ShieldCheck,
dotClass: 'bg-green-500',
textClass: 'text-green-600',
};
case 'campaign_marked_false_positive':
return {
label: 'Marked as false positive',
icon: XCircle,
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';
return {
label: verdict ? `Classified as ${verdict}` : 'Campaign classified',
icon: Sparkles,
dotClass: tint.split(' ')[0],
textClass: tint.split(' ')[1],
};
}
default:
return {
label: humanizeEventType(entry.eventType),
icon: Circle,
dotClass: 'bg-muted-foreground',
textClass: 'text-muted-foreground',
};
}
}
export function TimelineCard({ timeline }: TimelineCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="font-bold">Timeline</CardTitle>
</CardHeader>
<CardContent>
{timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">No timeline events yet.</p>
) : (
<div className="space-y-0">
{timeline.map((entry, idx) => {
const { label, icon: Icon, dotClass, textClass } = renderEntry(entry);
const isLast = idx === timeline.length - 1;
const actor = entry.kind === 'audit' ? entry.actor : null;
const absolute = new Date(entry.at).toLocaleString();
const relative = formatDistanceToNow(new Date(entry.at), { addSuffix: true });
return (
<div key={idx} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn('h-2 w-2 rounded-full shrink-0 mt-1.5', dotClass)}
/>
{!isLast && <span className="border-l flex-1 w-0 min-h-4" />}
</div>
<div className="pb-4 min-w-0">
<div className="flex items-center gap-1.5 text-sm">
<Icon className={cn('h-3.5 w-3.5 shrink-0', textClass)} />
<span>{label}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-xs text-muted-foreground" title={absolute}>
{relative}
</span>
{actor && (
<span className="text-xs text-muted-foreground">{actor}</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}