- 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
163 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|