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,
|
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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue