- 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)
171 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|