Merge branch 'worktree-agent-a2447e1cd43bb4c28'
This commit is contained in:
commit
897bc67b9e
6 changed files with 791 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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