feat(22-06): ticket-scoped LiveLink review page (REVIEW-01,05,06)
- app/phishing/tickets/[ticketId]/page.tsx: resolves ticket->campaign via
the plan-02 resolver route, drives a loading/not-triaged/ungrouped/ready/
error state machine, branches ready into grouped-but-unclassified
(Classify CTA, no ClassificationCard/ActionAreaCard) vs. classified (all
four cards with explicit props), refetches after every action (D-04),
session-only auth (no token/query-param scheme)
- app/api/phishing/reports/[report_id]/route.ts (new, additive): thin
report-scoped evidence + fresh blast-radius lookup for the D-08
ungrouped-report state, which has no campaignId to key the existing
campaign-detail route on — added as a Rule 2 dependency since the plan's
own D-08 truth ("standalone-report notice + evidence") has no other data
source
This commit is contained in:
parent
bced734717
commit
3761312f93
2 changed files with 485 additions and 0 deletions
146
app/api/phishing/reports/[report_id]/route.ts
Normal file
146
app/api/phishing/reports/[report_id]/route.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* GET /api/phishing/reports/{report_id}
|
||||
* Standalone report evidence lookup for the D-08 "ungrouped report" state
|
||||
* on the ticket-scoped review page (Phase 22 plan 06): a `reports` row
|
||||
* that hasn't been linked to a campaign yet (`campaign_id IS NULL`, the
|
||||
* narrow race window before Phase 18's grouping runs). Returns just
|
||||
* enough — the report's own linked message evidence + a fresh
|
||||
* blast-radius lookup — to render <EvidenceCard> standalone, without a
|
||||
* campaign wrapper. Mirrors the bulk-fetch idiom in
|
||||
* app/api/phishing/campaigns/[id]/route.ts, scoped to a single report_id
|
||||
* instead of a campaign_id.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { getBlastRadius, type BlastRadiusResult } from '@/lib/services/mimecast-blast-radius';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
interface ReportRow {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
ticket_number: string | null;
|
||||
company_name: string | null;
|
||||
title: string | null;
|
||||
created_at: string;
|
||||
requester_email: string | null;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
message_id: string | null;
|
||||
headers: unknown;
|
||||
urls: unknown;
|
||||
attachments: unknown;
|
||||
body_preview: string | null;
|
||||
}
|
||||
|
||||
interface IndicatorRow {
|
||||
id: string;
|
||||
message_id: string;
|
||||
indicator_type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ report_id: string }> }
|
||||
) {
|
||||
const { error } = await requirePermission('phishing', 'read');
|
||||
if (error) return error;
|
||||
|
||||
const { report_id } = await params;
|
||||
if (!UUID_RE.test(report_id)) {
|
||||
return NextResponse.json({ error: 'Invalid report id' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const reportRes = await postgresClient.query<ReportRow>(
|
||||
`SELECT r.id::text, r.ticket_id::text, r.ticket_number, r.company_name,
|
||||
r.title, r.created_at::text,
|
||||
c.email_address AS requester_email
|
||||
FROM reports r
|
||||
LEFT JOIN contacts c ON c.id = r.requester_contact_id
|
||||
WHERE r.id = $1`,
|
||||
[report_id]
|
||||
);
|
||||
const report = reportRes.rows[0];
|
||||
if (!report) {
|
||||
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const messagesRes = await postgresClient.query<MessageRow>(
|
||||
`SELECT id::text, message_id, headers, urls, attachments, body_preview
|
||||
FROM messages WHERE report_id = $1 ORDER BY created_at ASC`,
|
||||
[report_id]
|
||||
);
|
||||
const messageIds = messagesRes.rows.map((m) => m.id);
|
||||
|
||||
const indicatorsRes = messageIds.length
|
||||
? await postgresClient.query<IndicatorRow>(
|
||||
`SELECT id::text, message_id::text, indicator_type, value
|
||||
FROM indicators WHERE message_id = ANY($1::uuid[])`,
|
||||
[messageIds]
|
||||
)
|
||||
: { rows: [] as IndicatorRow[] };
|
||||
|
||||
// Evidence shape matches EvidenceMessage (components/phishing/evidence-card.tsx):
|
||||
// a single ungrouped report has at most one linked message today, but this
|
||||
// returns an array for shape-compatibility with EvidenceCard's multi-message Select.
|
||||
const messages = messagesRes.rows.map((m) => ({
|
||||
id: m.id,
|
||||
ticketNumber: report.ticket_number,
|
||||
reportCreatedAt: report.created_at,
|
||||
headers: m.headers,
|
||||
urls: m.urls,
|
||||
attachments: m.attachments,
|
||||
bodyPreview: m.body_preview ?? '',
|
||||
}));
|
||||
|
||||
// Fresh blast-radius lookup (D-03: never persisted here), same derivation
|
||||
// as app/api/phishing/campaigns/[id]/route.ts — sender/recipient/subject/
|
||||
// dateWindow sourced from this report's primary (first) message.
|
||||
const primaryMessage = messagesRes.rows[0] ?? null;
|
||||
let blastRadius: BlastRadiusResult;
|
||||
if (primaryMessage) {
|
||||
const senderIndicator = indicatorsRes.rows.find(
|
||||
(i) => i.message_id === primaryMessage.id && i.indicator_type === 'sender'
|
||||
);
|
||||
const messageHeaders = (primaryMessage.headers ?? null) as
|
||||
| { from?: { email?: string | null } | null; subject?: string | null }
|
||||
| null;
|
||||
const createdAt = new Date(report.created_at);
|
||||
blastRadius = await getBlastRadius({
|
||||
sender: senderIndicator?.value ?? messageHeaders?.from?.email ?? '',
|
||||
recipient: report.requester_email ?? '',
|
||||
subject: messageHeaders?.subject ?? report.title ?? '',
|
||||
dateWindow: {
|
||||
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
blastRadius = { status: 'unavailable', reason: 'not_configured' };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: report.id,
|
||||
ticketId: report.ticket_id,
|
||||
ticketNumber: report.ticket_number,
|
||||
companyName: report.company_name,
|
||||
title: report.title,
|
||||
createdAt: report.created_at,
|
||||
requesterEmail: report.requester_email,
|
||||
messages,
|
||||
blastRadius,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[PHISHING-REPORT-DETAIL] Failed to load report', report_id, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load report', message: err instanceof Error ? err.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
339
app/phishing/tickets/[ticketId]/page.tsx
Normal file
339
app/phishing/tickets/[ticketId]/page.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* Ticket-scoped phishing campaign review page — the Autotask LiveLink target
|
||||
* (REVIEW-01, REVIEW-05, REVIEW-06). Resolves the URL's numeric ticket id to
|
||||
* its campaign via a two-step fetch, drives a
|
||||
* loading/not-triaged/ungrouped/ready/error state machine, and composes the
|
||||
* plan-03/04/05 cards. Authentication is the existing Better Auth session
|
||||
* only (this route is not in middleware.ts's publicRoutes) — no separate
|
||||
* token/query-param auth scheme.
|
||||
*/
|
||||
|
||||
import { use, useCallback, useEffect, useState } from 'react';
|
||||
import { SearchX, Sparkles } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SkeletonCard, SkeletonHeader } from '@/components/ui/skeleton-helpers';
|
||||
import { toast } from 'sonner';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { hasPermission } from '@/lib/permissions';
|
||||
import { ClassificationCard, type ClassificationCardData } from '@/components/phishing/classification-card';
|
||||
import { ActionAreaCard, type RemediationActionSummary } from '@/components/phishing/action-area-card';
|
||||
import {
|
||||
EvidenceCard,
|
||||
type EvidenceMessage,
|
||||
type BlastRadiusResult,
|
||||
} from '@/components/phishing/evidence-card';
|
||||
import { TimelineCard, type TimelineEntry } from '@/components/phishing/timeline-card';
|
||||
|
||||
type PageState = 'loading' | 'not-triaged' | 'ungrouped' | 'ready' | 'error';
|
||||
|
||||
interface TicketCampaignResolution {
|
||||
found: boolean;
|
||||
reportId?: string;
|
||||
campaignId?: string | null;
|
||||
ticketNumber?: string | null;
|
||||
}
|
||||
|
||||
interface CampaignReport {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
ticketNumber: string | null;
|
||||
companyName: string | null;
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
requesterEmail: string | null;
|
||||
}
|
||||
|
||||
interface CampaignMessage {
|
||||
id: string;
|
||||
reportId: string;
|
||||
messageId: string | null;
|
||||
subject: string | null;
|
||||
headers: EvidenceMessage['headers'];
|
||||
urls: EvidenceMessage['urls'];
|
||||
attachments: EvidenceMessage['attachments'];
|
||||
bodyPreview: string | null;
|
||||
}
|
||||
|
||||
interface CampaignDetail {
|
||||
id: string;
|
||||
campaignKey: string | null;
|
||||
groupMethod: string | null;
|
||||
firstSeenAt: string | null;
|
||||
lastSeenAt: string | null;
|
||||
reportCount: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
reports: CampaignReport[];
|
||||
messages: CampaignMessage[];
|
||||
classifications: ClassificationCardData[];
|
||||
remediationActions: RemediationActionSummary[];
|
||||
blastRadius: BlastRadiusResult;
|
||||
timeline: TimelineEntry[];
|
||||
}
|
||||
|
||||
interface StandaloneReport {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
ticketNumber: string | null;
|
||||
companyName: string | null;
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
requesterEmail: string | null;
|
||||
messages: EvidenceMessage[];
|
||||
blastRadius: BlastRadiusResult;
|
||||
}
|
||||
|
||||
/** `open` reads as "Awaiting triage" per UI-SPEC; everything else is a snake_case -> Title Case fallback. */
|
||||
function humanizeStatus(status: string): string {
|
||||
if (status === 'open') return 'Awaiting triage';
|
||||
return status
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export default function TicketReviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ ticketId: string }>;
|
||||
}) {
|
||||
const { ticketId } = use(params);
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
||||
const canClassify = hasPermission(role, 'phishing', 'analyze');
|
||||
|
||||
const [state, setState] = useState<PageState>('loading');
|
||||
const [resolution, setResolution] = useState<TicketCampaignResolution | null>(null);
|
||||
const [campaignId, setCampaignId] = useState<string | null>(null);
|
||||
const [campaignDetail, setCampaignDetail] = useState<CampaignDetail | null>(null);
|
||||
const [standaloneReport, setStandaloneReport] = useState<StandaloneReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isClassifying, setIsClassifying] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setState('loading');
|
||||
setError(null);
|
||||
try {
|
||||
const resolveRes = await fetch(`/api/phishing/tickets/${ticketId}/campaign`);
|
||||
if (!resolveRes.ok) throw new Error(`Request failed: ${resolveRes.status}`);
|
||||
const resolved: TicketCampaignResolution = await resolveRes.json();
|
||||
setResolution(resolved);
|
||||
|
||||
if (!resolved.found) {
|
||||
setState('not-triaged');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolved.campaignId) {
|
||||
// D-08: report exists but grouping hasn't linked it to a campaign yet.
|
||||
const reportRes = await fetch(`/api/phishing/reports/${resolved.reportId}`);
|
||||
if (!reportRes.ok) throw new Error(`Request failed: ${reportRes.status}`);
|
||||
const report: StandaloneReport = await reportRes.json();
|
||||
setStandaloneReport(report);
|
||||
setCampaignId(null);
|
||||
setCampaignDetail(null);
|
||||
setState('ungrouped');
|
||||
return;
|
||||
}
|
||||
|
||||
const detailRes = await fetch(`/api/phishing/campaigns/${resolved.campaignId}`);
|
||||
if (!detailRes.ok) throw new Error(`Request failed: ${detailRes.status}`);
|
||||
const detail: CampaignDetail = await detailRes.json();
|
||||
setCampaignId(resolved.campaignId);
|
||||
setCampaignDetail(detail);
|
||||
setStandaloneReport(null);
|
||||
setState('ready');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setState('error');
|
||||
}
|
||||
}, [ticketId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function handleAnalyze() {
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
const res = await fetch(`/api/phishing/tickets/${ticketId}/analyze`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Analyze failed');
|
||||
toast.success('Ticket analyzed');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(`Analyze failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClassify() {
|
||||
if (!campaignId) return;
|
||||
setIsClassifying(true);
|
||||
try {
|
||||
const res = await fetch(`/api/phishing/campaigns/${campaignId}/classify`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Classify failed');
|
||||
toast.success('Campaign classified');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(`Classify failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsClassifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ticketNumberLabel = resolution?.ticketNumber ?? ticketId;
|
||||
const description =
|
||||
state === 'ready' && campaignDetail
|
||||
? humanizeStatus(campaignDetail.status)
|
||||
: state === 'not-triaged'
|
||||
? 'Not yet triaged'
|
||||
: state === 'ungrouped'
|
||||
? 'Grouping in progress'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-6 py-6 space-y-6">
|
||||
<PageHeader
|
||||
title={`Ticket #${ticketNumberLabel}`}
|
||||
description={description}
|
||||
breadcrumbs={[
|
||||
{ label: 'Phishing', href: '/phishing' },
|
||||
{ label: `Ticket #${ticketNumberLabel}` },
|
||||
]}
|
||||
/>
|
||||
|
||||
{state === 'loading' && (
|
||||
<div className="space-y-6">
|
||||
<SkeletonHeader />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'not-triaged' && (
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="Not yet triaged"
|
||||
description="This ticket hasn't been scanned for phishing indicators yet. Run analysis now to extract evidence and see a classification."
|
||||
action={{ label: isAnalyzing ? 'Analyzing…' : 'Analyze this ticket', onClick: handleAnalyze }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'ungrouped' && standaloneReport && (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Grouping in progress — this report hasn't been linked to a campaign yet. The
|
||||
evidence below is from this report only; classification and remediation will appear
|
||||
once grouping completes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<EvidenceCard messages={standaloneReport.messages} blastRadius={standaloneReport.blastRadius} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>Couldn't load this campaign. {error} — try reloading the page.</span>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{state === 'ready' &&
|
||||
campaignDetail &&
|
||||
campaignId &&
|
||||
(() => {
|
||||
// Single latest classification (Warning 3): a freshly-grouped
|
||||
// campaign returns classifications: [] -> null, the default state
|
||||
// since detection/grouping never auto-triggers classification.
|
||||
const classification = campaignDetail.classifications[0] ?? null;
|
||||
const primaryReport = campaignDetail.reports[0] ?? null;
|
||||
const primaryMessage = primaryReport
|
||||
? campaignDetail.messages.find((m) => m.reportId === primaryReport.id) ?? null
|
||||
: null;
|
||||
|
||||
const evidenceMessages: EvidenceMessage[] = campaignDetail.messages.map((m) => {
|
||||
const report = campaignDetail.reports.find((r) => r.id === m.reportId);
|
||||
return {
|
||||
id: m.id,
|
||||
ticketNumber: report?.ticketNumber ?? null,
|
||||
reportCreatedAt: report?.createdAt ?? null,
|
||||
headers: m.headers,
|
||||
urls: m.urls,
|
||||
attachments: m.attachments,
|
||||
bodyPreview: m.bodyPreview ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
const evidence = {
|
||||
requesterEmail: primaryReport?.requesterEmail ?? null,
|
||||
senderEmail: primaryMessage?.headers.from.email ?? null,
|
||||
senderDomain: primaryMessage?.headers.from.domain ?? null,
|
||||
messageId: primaryMessage?.messageId ?? null,
|
||||
};
|
||||
|
||||
if (classification == null) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
title="Not yet classified"
|
||||
description="This campaign is grouped but hasn't been classified yet. Run classification to generate a verdict and recommended remediation actions."
|
||||
action={
|
||||
canClassify
|
||||
? {
|
||||
label: isClassifying ? 'Classifying…' : 'Classify this campaign',
|
||||
onClick: handleClassify,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<EvidenceCard messages={evidenceMessages} blastRadius={campaignDetail.blastRadius} />
|
||||
<TimelineCard timeline={campaignDetail.timeline} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ClassificationCard
|
||||
campaignId={campaignId}
|
||||
classification={classification}
|
||||
onReclassified={load}
|
||||
/>
|
||||
<ActionAreaCard
|
||||
campaignId={campaignId}
|
||||
classification={classification}
|
||||
remediationActions={campaignDetail.remediationActions}
|
||||
campaignStatus={campaignDetail.status}
|
||||
campaignUpdatedAt={campaignDetail.updatedAt}
|
||||
evidence={evidence}
|
||||
onActionComplete={load}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<EvidenceCard messages={evidenceMessages} blastRadius={campaignDetail.blastRadius} />
|
||||
<TimelineCard timeline={campaignDetail.timeline} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue