wulf-pulse/components/phishing/classification-card.tsx
lorentz af8a5bd438 feat(23-02): add USER_AWARENESS badge variant + acknowledge_user label to ClassificationCard
- Add USER_AWARENESS to ClassificationCardData verdict union
- Add emerald VERDICT_VARIANT_CLASS entry for USER_AWARENESS (distinct from UNWANTED amber and THREAT destructive)
- Add acknowledge_user: 'Acknowledge user' to ACTION_LABEL
2026-07-16 19:35:22 -04:00

163 lines
5.4 KiB
TypeScript

'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' | 'USER_AWARENESS';
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',
USER_AWARENESS: 'bg-emerald-500/15 text-emerald-600',
};
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',
acknowledge_user: 'Acknowledge user',
};
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 && Number.isFinite(Number(classification.confidence)) && (
<span className="text-sm text-muted-foreground">
{Math.round(Number(classification.confidence) * 100)}% 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>
);
}