- app/phishing/page.tsx: minimal DataTable-backed campaigns list, row click
navigates to /phishing/tickets/{firstReportTicketId}, EmptyState when no
campaigns exist yet
- components/navigation/app-navigation.tsx: add flat "Phishing" nav item
(ShieldAlert icon) immediately after PAX8, visible to all roles (every
role has phishing:read)
152 lines
4.6 KiB
TypeScript
152 lines
4.6 KiB
TypeScript
'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<string, string> = {
|
|
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 <span className="text-muted-foreground">—</span>;
|
|
const absolute = new Date(value).toLocaleString();
|
|
return (
|
|
<span className="font-mono text-xs" title={absolute}>
|
|
{formatDistanceToNow(new Date(value), { addSuffix: true })}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function PhishingCampaignsPage() {
|
|
const router = useRouter();
|
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
|
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<Campaign>[] = [
|
|
{
|
|
key: 'campaignKey',
|
|
label: 'Campaign',
|
|
render: (value: string | null, row: Campaign) => (
|
|
<span className="font-mono text-sm">{value ?? `Campaign ${row.id.slice(0, 8)}`}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
render: (value: string) => (
|
|
<StatusBadge variantClass={statusBadgeClass(value)}>{humanizeStatus(value)}</StatusBadge>
|
|
),
|
|
},
|
|
{
|
|
key: 'reportCount',
|
|
label: 'Reports',
|
|
render: (value: number) => <span className="num text-right block">{value}</span>,
|
|
},
|
|
{
|
|
key: 'firstSeenAt',
|
|
label: 'First seen',
|
|
render: (value: string | null) => relativeWithAbsolute(value),
|
|
},
|
|
{
|
|
key: 'lastSeenAt',
|
|
label: 'Last seen',
|
|
render: (value: string | null) => relativeWithAbsolute(value),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Phishing Campaigns"
|
|
description="Automatically detected phishing and spam campaigns awaiting triage."
|
|
/>
|
|
<main className="container mx-auto px-6 py-6">
|
|
{!isLoading && campaigns.length === 0 ? (
|
|
<EmptyState
|
|
icon={ShieldAlert}
|
|
title="No campaigns yet"
|
|
description="Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message."
|
|
/>
|
|
) : (
|
|
<DataTable
|
|
columns={columns}
|
|
data={campaigns}
|
|
totalCount={totalCount}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
onPageChange={setPage}
|
|
onRowClick={(campaign) => {
|
|
if (campaign.firstReportTicketId) {
|
|
router.push(`/phishing/tickets/${campaign.firstReportTicketId}`);
|
|
}
|
|
}}
|
|
isLoading={isLoading}
|
|
/>
|
|
)}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|