feat(22-06): campaigns list page + Phishing nav entry (D-00, D-02)

- 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)
This commit is contained in:
lorentz 2026-07-16 14:58:52 -04:00
parent 3761312f93
commit 5f5d809050
2 changed files with 159 additions and 0 deletions

152
app/phishing/page.tsx Normal file
View file

@ -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<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>
</>
);
}

View file

@ -19,6 +19,7 @@ import {
AlertTriangle, AlertTriangle,
ChevronDown, ChevronDown,
ShoppingCart, ShoppingCart,
ShieldAlert,
} from 'lucide-react'; } from 'lucide-react';
import { import {
NavigationMenu, NavigationMenu,
@ -67,6 +68,12 @@ const navigationItems: NavItem[] = [
icon: ShoppingCart, icon: ShoppingCart,
description: 'PAX8 companies, subscriptions, and cost breakdown' 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', title: 'Backup Status',
icon: HardDrive, icon: HardDrive,