diff --git a/components/phishing/classification-card.tsx b/components/phishing/classification-card.tsx new file mode 100644 index 0000000..017468b --- /dev/null +++ b/components/phishing/classification-card.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useState } from 'react'; +import { Sparkles, Loader2 } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { toast } from 'sonner'; +import { useSession } from '@/lib/auth-client'; +import { hasPermission } from '@/lib/permissions'; + +export interface ClassificationCardData { + id: string; + verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; + confidence: string | null; + summary: string | null; + reasons: string[]; + recommendedActions: string[]; + requiresApproval: boolean; + createdAt: string; +} + +interface ClassificationCardProps { + campaignId: string; + classification: ClassificationCardData | null; + onReclassified: () => void; +} + +const VERDICT_VARIANT_CLASS: Record = { + SPAM: 'bg-slate-500/15 text-slate-600', + UNWANTED: 'bg-amber-500/15 text-amber-600', + THREAT: 'bg-destructive/15 text-destructive', +}; + +const ACTION_LABEL: Record = { + 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', +}; + +function humanizeAction(actionType: string): string { + return ( + ACTION_LABEL[actionType] ?? + actionType + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + ); +} + +export function ClassificationCard({ + campaignId, + classification, + onReclassified, +}: ClassificationCardProps) { + const { data: session } = useSession(); + const [isReclassifying, setIsReclassifying] = useState(false); + const role = (session?.user as { role?: string } | undefined)?.role ?? 'user'; + const canReclassify = hasPermission(role, 'phishing', 'analyze'); + + // Guard: this card renders nothing for a null classification. The review + // page (not this card) chooses the replacement UI for both null-classification + // paths — the default "grouped but not yet classified" empty state (plan 06) + // and the D-08 ungrouped-report Alert (plan 06) — so we never dereference + // classification.verdict/.reasons/etc. here. + if (classification == null) return null; + + async function handleReclassify() { + setIsReclassifying(true); + try { + const res = await fetch(`/api/phishing/campaigns/${campaignId}/classify`, { + method: 'POST', + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? data.error ?? 'Reclassify failed'); + toast.success('Ticket reclassified'); + onReclassified(); + } catch (err) { + toast.error(`Reclassify failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + } finally { + setIsReclassifying(false); + } + } + + return ( + + +
+ + + Classification + + {canReclassify && ( + + )} +
+
+ +
+ + {classification.verdict} + + {classification.confidence != null && ( + + {classification.confidence}% confidence + + )} +
+ + {classification.summary && ( +

{classification.summary}

+ )} + + {classification.reasons.length > 0 && ( +
    + {classification.reasons.map((reason, idx) => ( +
  • {reason}
  • + ))} +
+ )} + + {classification.recommendedActions.length > 0 && ( +
+ {classification.recommendedActions.map((actionType) => ( + + {humanizeAction(actionType)} + + ))} +
+ )} + + {classification.requiresApproval && ( + + + This classification recommends a destructive action and requires + explicit approval before remediation can proceed. + + + )} +
+
+ ); +}