diff --git a/components/phishing/timeline-card.tsx b/components/phishing/timeline-card.tsx new file mode 100644 index 0000000..f1bcf81 --- /dev/null +++ b/components/phishing/timeline-card.tsx @@ -0,0 +1,153 @@ +'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'; 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', string> = { + SPAM: 'bg-slate-500 text-slate-600', + UNWANTED: 'bg-amber-500 text-amber-600', + THREAT: 'bg-destructive text-destructive', +}; + +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 confidence = entry.confidence != null ? ` (${entry.confidence}%)` : ''; + return { + label: `Classified as ${entry.verdict}${confidence}`, + icon: Sparkles, + dotClass: VERDICT_TINT[entry.verdict].split(' ')[0], + textClass: VERDICT_TINT[entry.verdict].split(' ')[1], + }; + } + + // entry.kind === 'audit' + const payload = (entry.payload ?? {}) as Record; + 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_classified': { + const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | 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 ( + + + Timeline + + + {timeline.length === 0 ? ( +

No timeline events yet.

+ ) : ( +
+ {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 ( +
+
+ + {!isLast && } +
+
+
+ + {label} +
+
+ + {relative} + + {actor && ( + {actor} + )} +
+
+
+ ); + })} +
+ )} +
+
+ ); +}