feat(22-04): add ClassificationCard read-only verdict display

- Renders latest classification verdict/confidence/summary/reasons
- Recommended-action chips (informational, no checkboxes)
- Requires-approval warning Alert when requiresApproval is true
- Reclassify button gated on hasPermission(role, 'phishing', 'analyze')
- Returns null when classification is missing (empty-state handled by plan 06)
This commit is contained in:
lorentz 2026-07-16 14:27:29 -04:00
parent 4b5e31c068
commit 14adddfdf0

View file

@ -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<ClassificationCardData['verdict'], string> = {
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<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',
};
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 (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<CardTitle className="font-bold">
<Sparkles className="h-4 w-4 mr-2 inline" />
Classification
</CardTitle>
{canReclassify && (
<Button
variant="outline"
size="sm"
disabled={isReclassifying}
onClick={handleReclassify}
>
{isReclassifying ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : null}
Reclassify ticket
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<StatusBadge variantClass={VERDICT_VARIANT_CLASS[classification.verdict]}>
{classification.verdict}
</StatusBadge>
{classification.confidence != null && (
<span className="text-sm text-muted-foreground">
{classification.confidence}% confidence
</span>
)}
</div>
{classification.summary && (
<p className="text-sm">{classification.summary}</p>
)}
{classification.reasons.length > 0 && (
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
{classification.reasons.map((reason, idx) => (
<li key={idx}>{reason}</li>
))}
</ul>
)}
{classification.recommendedActions.length > 0 && (
<div className="flex flex-wrap gap-2">
{classification.recommendedActions.map((actionType) => (
<StatusBadge
key={actionType}
variantClass="bg-slate-500/15 text-slate-600"
>
{humanizeAction(actionType)}
</StatusBadge>
))}
</div>
)}
{classification.requiresApproval && (
<Alert className="border-amber-500/50 text-amber-600 dark:border-amber-500 [&>svg]:text-amber-600">
<AlertDescription>
This classification recommends a destructive action and requires
explicit approval before remediation can proceed.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}