diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index df497ff..4a00f38 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -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 diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-SUMMARY.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-SUMMARY.md new file mode 100644 index 0000000..ba17232 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-SUMMARY.md @@ -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 `` 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 `` 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) diff --git a/app/api/phishing/reports/[report_id]/route.ts b/app/api/phishing/reports/[report_id]/route.ts new file mode 100644 index 0000000..c04bb9d --- /dev/null +++ b/app/api/phishing/reports/[report_id]/route.ts @@ -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 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( + `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( + `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( + `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 } + ); + } +} diff --git a/app/phishing/page.tsx b/app/phishing/page.tsx new file mode 100644 index 0000000..1cb2f7c --- /dev/null +++ b/app/phishing/page.tsx @@ -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 = { + 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 ; + const absolute = new Date(value).toLocaleString(); + return ( + + {formatDistanceToNow(new Date(value), { addSuffix: true })} + + ); +} + +export default function PhishingCampaignsPage() { + const router = useRouter(); + const [campaigns, setCampaigns] = useState([]); + 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[] = [ + { + key: 'campaignKey', + label: 'Campaign', + render: (value: string | null, row: Campaign) => ( + {value ?? `Campaign ${row.id.slice(0, 8)}`} + ), + }, + { + key: 'status', + label: 'Status', + render: (value: string) => ( + {humanizeStatus(value)} + ), + }, + { + key: 'reportCount', + label: 'Reports', + render: (value: number) => {value}, + }, + { + key: 'firstSeenAt', + label: 'First seen', + render: (value: string | null) => relativeWithAbsolute(value), + }, + { + key: 'lastSeenAt', + label: 'Last seen', + render: (value: string | null) => relativeWithAbsolute(value), + }, + ]; + + return ( + <> + +
+ {!isLoading && campaigns.length === 0 ? ( + + ) : ( + { + if (campaign.firstReportTicketId) { + router.push(`/phishing/tickets/${campaign.firstReportTicketId}`); + } + }} + isLoading={isLoading} + /> + )} +
+ + ); +} diff --git a/app/phishing/tickets/[ticketId]/page.tsx b/app/phishing/tickets/[ticketId]/page.tsx new file mode 100644 index 0000000..b424ee4 --- /dev/null +++ b/app/phishing/tickets/[ticketId]/page.tsx @@ -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('loading'); + const [resolution, setResolution] = useState(null); + const [campaignId, setCampaignId] = useState(null); + const [campaignDetail, setCampaignDetail] = useState(null); + const [standaloneReport, setStandaloneReport] = useState(null); + const [error, setError] = useState(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 ( +
+ + + {state === 'loading' && ( +
+ + + + +
+ )} + + {state === 'not-triaged' && ( + + )} + + {state === 'ungrouped' && standaloneReport && ( +
+ + + 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. + + + +
+ )} + + {state === 'error' && ( + + + Couldn't load this campaign. {error} — try reloading the page. + + + + )} + + {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 ( +
+ +
+ + +
+
+ ); + } + + return ( +
+ + +
+ + +
+
+ ); + })()} +
+ ); +} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index abef807..7c64594 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -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,