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:
parent
3761312f93
commit
5f5d809050
2 changed files with 159 additions and 0 deletions
152
app/phishing/page.tsx
Normal file
152
app/phishing/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue