diff --git a/app/phishing/page.tsx b/app/phishing/page.tsx new file mode 100644 index 0000000..1cb2f7c --- /dev/null +++ b/app/phishing/page.tsx @@ -0,0 +1,152 @@ +'use client'; + +/** + * Phishing Campaigns list page (D-00) — minimal entry point for the + * "Phishing" nav item. Browses recent campaigns and navigates into the + * ticket-scoped review page (`/phishing/tickets/{firstReportTicketId}`), + * which is the actual LiveLink-addressable surface (REVIEW-01..06). + */ + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ShieldAlert } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import DataTable, { type Column } from '@/components/admin/DataTable'; +import { PageHeader } from '@/components/navigation/page-header'; +import { EmptyState } from '@/components/ui/empty-state'; +import { StatusBadge } from '@/components/ui/status-badge'; + +interface Campaign { + id: string; + campaignKey: string | null; + groupMethod: string | null; + firstSeenAt: string | null; + lastSeenAt: string | null; + reportCount: number; + status: string; + createdAt: string; + firstReportTicketId: string | null; +} + +const STATUS_VARIANT_CLASS: Record = { + open: 'bg-slate-500/15 text-slate-600', + false_positive: 'bg-slate-500/15 text-slate-600', +}; + +function statusBadgeClass(status: string): string { + return STATUS_VARIANT_CLASS[status] ?? 'bg-slate-500/15 text-slate-600'; +} + +function humanizeStatus(status: string): string { + return status + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +function relativeWithAbsolute(value: string | null) { + if (!value) return ; + const absolute = new Date(value).toLocaleString(); + return ( + + {formatDistanceToNow(new Date(value), { addSuffix: true })} + + ); +} + +export default function PhishingCampaignsPage() { + const router = useRouter(); + const [campaigns, setCampaigns] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(50); + const [isLoading, setIsLoading] = useState(false); + + const fetchCampaigns = async (currentPage: number) => { + setIsLoading(true); + try { + const params = new URLSearchParams({ + limit: pageSize.toString(), + offset: String((currentPage - 1) * pageSize), + }); + const response = await fetch(`/api/phishing/campaigns?${params}`); + const result = await response.json(); + setCampaigns(result.items ?? []); + setTotalCount(result.total ?? 0); + } catch (error) { + console.error('Failed to fetch campaigns:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void fetchCampaigns(page); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page]); + + const columns: Column[] = [ + { + key: 'campaignKey', + label: 'Campaign', + render: (value: string | null, row: Campaign) => ( + {value ?? `Campaign ${row.id.slice(0, 8)}`} + ), + }, + { + key: 'status', + label: 'Status', + render: (value: string) => ( + {humanizeStatus(value)} + ), + }, + { + key: 'reportCount', + label: 'Reports', + render: (value: number) => {value}, + }, + { + key: 'firstSeenAt', + label: 'First seen', + render: (value: string | null) => relativeWithAbsolute(value), + }, + { + key: 'lastSeenAt', + label: 'Last seen', + render: (value: string | null) => relativeWithAbsolute(value), + }, + ]; + + return ( + <> + +
+ {!isLoading && campaigns.length === 0 ? ( + + ) : ( + { + if (campaign.firstReportTicketId) { + router.push(`/phishing/tickets/${campaign.firstReportTicketId}`); + } + }} + isLoading={isLoading} + /> + )} +
+ + ); +} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index abef807..7c64594 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -19,6 +19,7 @@ import { AlertTriangle, ChevronDown, ShoppingCart, + ShieldAlert, } from 'lucide-react'; import { NavigationMenu, @@ -67,6 +68,12 @@ const navigationItems: NavItem[] = [ icon: ShoppingCart, description: 'PAX8 companies, subscriptions, and cost breakdown' }, + { + title: 'Phishing', + href: '/phishing', + icon: ShieldAlert, + description: 'Phishing/spam campaign triage, evidence, and remediation approval' + }, { title: 'Backup Status', icon: HardDrive,