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:
parent
4b5e31c068
commit
14adddfdf0
1 changed files with 161 additions and 0 deletions
161
components/phishing/classification-card.tsx
Normal file
161
components/phishing/classification-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue