Merge branch 'worktree-agent-a2447e1cd43bb4c28'

This commit is contained in:
lorentz 2026-07-16 15:39:28 -04:00
commit 897bc67b9e
6 changed files with 791 additions and 6 deletions

View file

@ -111,7 +111,7 @@ destructive remediation gated behind explicit human approval.
### Approval UI (LiveLink)
- [ ] **REVIEW-01**: A stable, ticket-ID-addressable Pulse route (e.g.
- [x] **REVIEW-01**: A stable, ticket-ID-addressable Pulse route (e.g.
`/phishing/tickets/{ticketId}`) resolves the ticket to its campaign and
renders that campaign's review page, suitable as an Autotask LiveLink target
(LiveLink supplies the ticket ID as dynamic content, not the internal
@ -127,12 +127,12 @@ destructive remediation gated behind explicit human approval.
- [x] **REVIEW-04**: The page displays the current classification (SPAM/
UNWANTED/THREAT), confidence, reasons, and recommended remediation
action(s)
- [ ] **REVIEW-05**: An operator can approve, remediate, or mark a campaign as
- [x] **REVIEW-05**: An operator can approve, remediate, or mark a campaign as
a false positive directly from the page, calling the existing
`/api/phishing/campaigns/{id}` approve/remediate/mark-false-positive
endpoints and reflecting the resulting state (e.g. a remediated campaign
shows as remediated, not re-offered for approval)
- [ ] **REVIEW-06**: An operator without the elevated permission approve/
- [x] **REVIEW-06**: An operator without the elevated permission approve/
remediate already require sees those actions disabled or hidden rather than
a failed request; the page enforces no separate or relaxed permission model
from the underlying APIs
@ -203,12 +203,12 @@ Populated during roadmap creation.
| REMED-06 | Phase 20 | Complete |
| NOTE-01 | Phase 21 | Complete |
| ACCESS-01 | Phase 18 | Complete |
| REVIEW-01 | Phase 22 | Pending |
| REVIEW-01 | Phase 22 | Complete |
| REVIEW-02 | Phase 22 | Complete |
| REVIEW-03 | Phase 22 | Pending |
| REVIEW-04 | Phase 22 | Complete |
| REVIEW-05 | Phase 22 | Pending |
| REVIEW-06 | Phase 22 | Pending |
| REVIEW-05 | Phase 22 | Complete |
| REVIEW-06 | Phase 22 | Complete |
**Coverage:**
- v1 requirements: 32 total

View file

@ -0,0 +1,141 @@
---
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
plan: 06
subsystem: ui
tags: [nextjs, react, phishing, livelink, autotask, datatable, empty-state]
# Dependency graph
requires:
- phase: 22 plan 02
provides: ticket->campaign resolver route, extended campaign-detail route (reports/messages/classifications/remediationActions/blastRadius/timeline), campaigns list route with firstReportTicketId
- phase: 22 plan 03
provides: EvidenceCard + UrlList + tooltip primitive
- phase: 22 plan 04
provides: ClassificationCard + TimelineCard
- phase: 22 plan 05
provides: ActionAreaCard + remediation-default-params
provides:
- Ticket-scoped LiveLink review page at /phishing/tickets/{ticketId} composing all four phishing cards
- Minimal campaigns list page at /phishing (D-00)
- "Phishing" top-level nav entry (D-02)
- New GET /api/phishing/reports/{report_id} route for the D-08 ungrouped-report evidence state
- Confirmed (production) numeric-ticket-id LiveLink addressing assumption
affects: [phase-transition, milestone-close]
# Tech tracking
tech-stack:
added: []
patterns:
- "Client-side permission gating via hasPermission(role, resource, action) + useSession() — same function server routes enforce, no separate/relaxed check (REVIEW-06)"
- "Refetch-after-action (D-04): every mutating action passes `load` as a callback prop instead of optimistic local state mutation"
- "Ticket-scoped state machine: loading/not-triaged/ungrouped/ready/error, with `ready` further branching on classifications[0] ?? null"
key-files:
created:
- app/phishing/tickets/[ticketId]/page.tsx
- app/phishing/page.tsx
- app/api/phishing/reports/[report_id]/route.ts
modified:
- components/navigation/app-navigation.tsx
key-decisions:
- "Added a new GET /api/phishing/reports/{report_id} route (not in the plan's declared files_modified) as a Rule 2 deviation — the plan's own D-08 truth ('standalone-report notice + evidence for an ungrouped report') has no other data source once campaignId is null, since the existing campaign-detail route is keyed on campaignId"
- "Task 3 (blocking human-verify checkpoint) resolved as 'verified': a real production Autotask LiveLink click against ticket 699340 confirmed the URL path segment is the plain numeric ticket ID, matching the resolver's Number(ticket_id) assumption — no ticket_number fallback needed"
patterns-established:
- "First codebase instance of client-side hasPermission() gating tied 1:1 to the server-side permission check (no bespoke role-string comparisons)"
requirements-completed: [REVIEW-01, REVIEW-05, REVIEW-06]
# Metrics
duration: ~40min
completed: 2026-07-16
---
# Phase 22 Plan 06: LiveLink Review Page, Campaigns List & Nav Entry Summary
**Ticket-scoped `/phishing/tickets/{ticketId}` LiveLink review page composing ClassificationCard/ActionAreaCard/EvidenceCard/TimelineCard with a 5-state loading/not-triaged/ungrouped/ready/error machine, plus a minimal `/phishing` campaigns list and nav entry — numeric-ticket-id LiveLink addressing confirmed live in production.**
## Performance
- **Duration:** ~40 min
- **Completed:** 2026-07-16
- **Tasks:** 3 (2 automated + 1 blocking human-verify checkpoint)
- **Files modified:** 4 (3 created, 1 modified)
## Accomplishments
- Ticket-scoped review page (`app/phishing/tickets/[ticketId]/page.tsx`) resolves ticket→campaign via the plan-02 resolver, renders all five states (loading/not-triaged/ungrouped/ready/error), and inside `ready` branches on `classifications[0] ?? null` into grouped-but-unclassified (Classify CTA, no crash) vs. fully classified (all four cards, explicit props)
- Every mutating action (analyze/classify/approve/remediate/mark-false-positive/reclassify) refetches campaign state via a shared `load()` callback — no optimistic local mutation (D-04)
- Minimal campaigns list page (`app/phishing/page.tsx`) with DataTable, EmptyState, and row-click navigation to the ticket-scoped page
- "Phishing" nav entry added to `components/navigation/app-navigation.tsx`, visible to every role (phishing:read is universal)
- New `GET /api/phishing/reports/{report_id}` route fills the D-08 ungrouped-report evidence gap (see Deviations)
- Task 3 blocking checkpoint resolved: a real production LiveLink click against ticket 699340 (`https://pulse.wulfconsulting.cloud/phishing/tickets/699340`) confirmed the URL renders the plain numeric Autotask ticket ID as the path segment — the resolver's `Number(ticket_id)` assumption is correct, no `ticket_number` fallback needed
## Task Commits
Each task was committed atomically:
1. **Task 1: Ticket-scoped review page (REVIEW-01, REVIEW-05, REVIEW-06)** - `3761312` (feat)
2. **Task 2: Campaigns list page (D-00) + Phishing nav entry (D-02)** - `5f5d809` (feat)
3. **Task 3: LiveLink numeric-ticket-id manual verification** - verified via a real production LiveLink click on ticket 699340 (2026-07-16); no code change required, no separate commit
**Plan metadata:** (this commit)
## Files Created/Modified
- `app/phishing/tickets/[ticketId]/page.tsx` - LiveLink-addressable ticket review page; state machine + card composition
- `app/api/phishing/reports/[report_id]/route.ts` - new: standalone report evidence + fresh blast-radius lookup for D-08
- `app/phishing/page.tsx` - campaigns list page (DataTable, EmptyState, row-click navigation)
- `components/navigation/app-navigation.tsx` - added `ShieldAlert` import + "Phishing" nav item after PAX8
## Decisions Made
- Task 3 resolved "verified" on the strength of a real production LiveLink click (ticket 699340, 2026-07-16) rather than a synthetic/staging test — the numeric-ticket-id assumption is now confirmed against the live Autotask tenant, closing 22-RESEARCH's Open Question. The page itself rendered blank in that same click-through, but that is a separate, already-acknowledged infra/deploy gap (production container running a build that predates this feature and phases 15-21), not a code-correctness issue — a deploy was in progress separately. Ticket 699340 is also not itself a phishing report, so even post-deploy it would only exercise the D-07 "Not yet triaged" empty state, which is expected.
- Extended the campaign-detail-route bulk-fetch idiom (query by id, `.map()` to camelCase, fresh `getBlastRadius()` call) to a new report-scoped route rather than overloading the existing ticket→campaign resolver's locked `{found, reportId, campaignId, ticketNumber}` response shape (that shape was already implemented and verified in plan 22-02 — widening it risked breaking an already-shipped contract).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing Critical Functionality] Added GET /api/phishing/reports/{report_id} for the D-08 ungrouped-report evidence state**
- **Found during:** Task 1 (ticket-scoped review page)
- **Issue:** The plan's own `must_haves.truths` requires "a standalone-report notice + evidence for D-08" (a `reports` row exists but `campaign_id IS NULL` — the narrow race window before grouping runs) and the plan's Task 1 action text says the `ungrouped` state should "fetch that report's evidence for the standalone EvidenceCard." No existing route can supply this: the ticket→campaign resolver route returns only `{found, reportId, campaignId, ticketNumber}` (a locked, already-verified shape from plan 22-02), and the campaign-detail route is keyed on a non-null `campaignId`, which an ungrouped report by definition doesn't have. Without a data source, the D-08 truth could not be satisfied and `<EvidenceCard>` would have nothing to render.
- **Fix:** Added a new, additive `GET /api/phishing/reports/{report_id}` route mirroring the existing campaign-detail route's bulk-fetch idiom (`requirePermission('phishing', 'read')`, UUID validation, report/message/indicator queries, a fresh `getBlastRadius()` lookup using the same sender/recipient/subject/dateWindow derivation), scoped to a single report instead of a campaign. Returns `{ id, ticketId, ticketNumber, companyName, title, createdAt, requesterEmail, messages: EvidenceMessage[], blastRadius }` — the exact shape `<EvidenceCard>` consumes.
- **Files modified:** `app/api/phishing/reports/[report_id]/route.ts` (new)
- **Verification:** `npx tsc --noEmit --pretty` clean; route follows the identical auth/validation/error-handling pattern as the five other existing phishing routes (confirmed via grep comparison against `app/api/phishing/campaigns/[id]/route.ts`)
- **Committed in:** `3761312` (Task 1 commit)
---
**Total deviations:** 1 auto-fixed (1 missing critical functionality)
**Impact on plan:** Necessary for the D-08 ungrouped-report state (an explicit `must_haves.truths` requirement) to actually render evidence instead of crashing or silently omitting it. No scope creep — the new route is additive, doesn't modify any existing route's response shape, and follows established conventions exactly.
## Issues Encountered
- Task 3 (blocking human-verify checkpoint) could not be performed by the executor — it requires a real Autotask LiveLink click against a live production tenant. Resolved externally by the coordinator: a real click against ticket 699340 in production confirmed the numeric-ticket-id assumption. See Decisions Made above for full detail, including the separately-tracked production deploy gap (unrelated to this plan's code).
## User Setup Required
None - no external service configuration required. (The production deploy needed to actually serve this code is tracked separately by the coordinator, outside this plan's scope.)
## Next Phase Readiness
- All three tasks of the final phase-22 plan are complete: REVIEW-01, REVIEW-05, REVIEW-06 requirements now marked Complete in REQUIREMENTS.md.
- REVIEW-03 (EvidenceCard, owned by plan 22-03) remains marked Pending in REQUIREMENTS.md's traceability table — it was not in this plan's declared `requirements` frontmatter and is out of this plan's scope to close; noted here so phase-transition tooling doesn't silently miss it.
- Phase 22 (and the v3.0 Phishing Triage Automation milestone) has no further plans queued after this one, pending the production deploy referenced above and a full manual click-through (per 22-VALIDATION.md's Manual-Only section) once that deploy lands.
---
*Phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve*
*Completed: 2026-07-16*
## Self-Check: PASSED
- FOUND: `app/phishing/tickets/[ticketId]/page.tsx`
- FOUND: `app/phishing/page.tsx`
- FOUND: `app/api/phishing/reports/[report_id]/route.ts`
- FOUND: `components/navigation/app-navigation.tsx`
- FOUND: `.planning/REQUIREMENTS.md` (REVIEW-01/05/06 marked complete)
- FOUND commit: `3761312` (Task 1)
- FOUND commit: `5f5d809` (Task 2)
- Task 3: verified externally via a real production LiveLink click (ticket 699340, 2026-07-16) — no code commit associated (no fallback needed)

View 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 }
);
}
}

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

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

View file

@ -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,