feat(23-04): build /admin/phishing-automation page with per-stage toggles

- Company table with search + type filter (cloned from client-scope pattern)
- Three independent Switch toggles per row: auto-parse, auto-classify, auto-report
- toggle() PATCHes /api/admin/phishing-automation/{companyId} with all three current flags
- Helper caption clarifies stage dependency (informational, not enforced)
This commit is contained in:
lorentz 2026-07-16 19:46:16 -04:00
parent b69558aae8
commit 5dabaad722

View file

@ -0,0 +1,266 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/navigation/page-header';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Search, RefreshCw, ShieldCheck, Zap } from 'lucide-react';
interface Company {
id: string;
companyName: string;
companyType: number | null;
companyTypeLabel: string | null;
autoParse: boolean;
autoClassify: boolean;
autoReport: boolean;
}
type Stage = 'autoParse' | 'autoClassify' | 'autoReport';
const STAGE_LABEL: Record<Stage, string> = {
autoParse: 'Auto-parse',
autoClassify: 'Auto-classify',
autoReport: 'Auto-report',
};
export default function PhishingAutomationPage() {
const [companies, setCompanies] = useState<Company[]>([]);
const [total, setTotal] = useState(0);
const [enabled, setEnabled] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [toggling, setToggling] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (typeFilter !== 'all') params.set('type', typeFilter);
const qs = params.toString();
const res = await fetch('/api/admin/phishing-automation' + (qs ? `?${qs}` : ''));
if (!res.ok) {
const d = await res.json().catch(() => ({})) as { error?: string };
throw new Error(d.error ?? `Request failed: ${res.status}`);
}
const data = await res.json() as { companies: Company[]; total: number; enabled: number };
setCompanies(data.companies);
setTotal(data.total);
setEnabled(data.enabled);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
} finally {
setLoading(false);
}
}, [search, typeFilter]);
useEffect(() => {
void load();
}, [load]);
async function toggle(company: Company, stage: Stage, next: boolean) {
setToggling(`${company.id}:${stage}`);
const nextFlags = {
autoParse: stage === 'autoParse' ? next : company.autoParse,
autoClassify: stage === 'autoClassify' ? next : company.autoClassify,
autoReport: stage === 'autoReport' ? next : company.autoReport,
};
try {
const res = await fetch(`/api/admin/phishing-automation/${company.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(nextFlags),
});
if (!res.ok) {
const d = await res.json().catch(() => ({})) as { error?: string };
throw new Error(d.error ?? `Failed: ${res.status}`);
}
setCompanies((prev) =>
prev.map((c) => (c.id === company.id ? { ...c, ...nextFlags } : c))
);
setEnabled((n) => {
const wasEnabled = company.autoParse || company.autoClassify || company.autoReport;
const isEnabled = nextFlags.autoParse || nextFlags.autoClassify || nextFlags.autoReport;
if (wasEnabled === isEnabled) return n;
return isEnabled ? n + 1 : Math.max(0, n - 1);
});
toast.success(`${STAGE_LABEL[stage]} ${next ? 'enabled' : 'disabled'} for ${company.companyName}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Toggle failed');
} finally {
setToggling(null);
}
}
const uniqueTypes = Array.from(new Set(companies.map((c) => c.companyType).filter(Boolean))).sort() as number[];
return (
<>
<PageHeader
title="Phishing Automation"
description="Opt in each company to automatic phishing-report parsing, classification, and reporting. Auto-report only auto-posts the acknowledge_user thank-you note for User Awareness (confirmed phishing-simulation) verdicts — every other verdict still requires manual approval regardless of this setting."
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Phishing Automation' }]}
accent
actions={
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
}
/>
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
{/* Summary */}
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Total companies:</span>
<span className="font-semibold num">{total}</span>
</div>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-emerald-500" />
<span className="text-muted-foreground">With any stage enabled:</span>
<span className="font-semibold num text-emerald-600 dark:text-emerald-400">{enabled}</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Stages are independent toggles, but they build on each other in practice: auto-report is only
meaningful when auto-classify is also on, and auto-classify is only meaningful when auto-parse
is also on. This is informational the UI does not enforce or auto-cascade these dependencies.
</p>
<Card>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 p-4 border-b">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search companies…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{uniqueTypes.map((t) => (
<SelectItem key={t} value={String(t)}>
Type {t}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground ml-auto">
{companies.length} shown
</span>
</div>
{error && (
<div className="p-4">
<Alert variant="destructive">
<AlertTitle>Failed to load</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)}
<CardContent className="p-0">
{loading ? (
<div className="p-6 space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : companies.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
No companies match the current filters.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead className="w-28">Type</TableHead>
<TableHead className="w-28 text-right">Auto-parse</TableHead>
<TableHead className="w-28 text-right">Auto-classify</TableHead>
<TableHead className="w-28 text-right">Auto-report</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((company) => (
<TableRow key={company.id}>
<TableCell className="font-medium">{company.companyName}</TableCell>
<TableCell>
{company.companyTypeLabel ? (
<Badge variant="outline" className="text-xs">
{company.companyTypeLabel}
</Badge>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
<TableCell className="text-right">
<Switch
checked={company.autoParse}
disabled={toggling === `${company.id}:autoParse`}
onCheckedChange={(next) => void toggle(company, 'autoParse', next)}
aria-label={`Auto-parse for ${company.companyName}`}
/>
</TableCell>
<TableCell className="text-right">
<Switch
checked={company.autoClassify}
disabled={toggling === `${company.id}:autoClassify`}
onCheckedChange={(next) => void toggle(company, 'autoClassify', next)}
aria-label={`Auto-classify for ${company.companyName}`}
/>
</TableCell>
<TableCell className="text-right">
<Switch
checked={company.autoReport}
disabled={toggling === `${company.id}:autoReport`}
onCheckedChange={(next) => void toggle(company, 'autoReport', next)}
aria-label={`Auto-report for ${company.companyName}`}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}