diff --git a/.planning/STATE.md b/.planning/STATE.md index c6a4b7d..7cd59a5 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,16 +2,16 @@ gsd_state_version: 1.0 milestone: v3.0 milestone_name: Phishing Triage Automation -status: planning -stopped_at: Phase 22 context gathered -last_updated: "2026-07-16T16:49:08.319Z" -last_activity: 2026-07-16 +status: executing +stopped_at: Phase 22 UI-SPEC approved +last_updated: "2026-07-16T18:09:53.334Z" +last_activity: 2026-07-16 -- Phase 22 planning complete progress: total_phases: 8 completed_phases: 7 - total_plans: 18 + total_plans: 24 completed_plans: 18 - percent: 88 + percent: 75 --- # Project State @@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-14) Phase: 22 Plan: Not started -Status: Ready to plan -Last activity: 2026-07-16 +Status: Ready to execute +Last activity: 2026-07-16 -- Phase 22 planning complete Progress: [░░░░░░░░░░] 0% @@ -126,7 +126,7 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-07-16T16:49:08.315Z -Stopped at: Phase 22 context gathered -Resume file: .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-CONTEXT.md +Last session: 2026-07-16T17:19:02.949Z +Stopped at: Phase 22 UI-SPEC approved +Resume file: .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-PLAN.md index 7d4c395..e1692e2 100644 --- a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-PLAN.md +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-PLAN.md @@ -12,10 +12,11 @@ autonomous: true requirements: [REVIEW-03] must_haves: truths: - - "Extracted URLs render as inert monospace text with a copy-to-clipboard button — never as a clickable link" + - "Extracted URLs render as inert monospace text with a copy-to-clipboard button — never as a clickable link (D-09)" - "The evidence card shows parsed Headers, URLs, Attachments, Body preview, and Blast Radius tabs for a selected message" - "Body preview renders as plain text in a
, never via dangerouslySetInnerHTML"
- "Blast radius renders an explicit 'unavailable' state when status is unavailable, and counts + per-recipient table when ok"
+ - "Attachment metadata (filename, content-type, size, hash) is displayed as plain evidence, as-is, with no extra redaction (D-10)"
artifacts:
- path: "components/ui/tooltip.tsx"
provides: "shadcn tooltip primitive (for later disabled-button explanations)"
diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md
index d8ec3f2..4a7ac8b 100644
--- a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md
+++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md
@@ -10,9 +10,9 @@ autonomous: true
requirements: [REVIEW-05, REVIEW-06]
must_haves:
truths:
- - "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call"
- - "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation)"
- - "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces, or when the campaign is already resolved"
+ - "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call (D-03)"
+ - "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation) (D-04)"
+ - "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces (D-06), or when the campaign is already resolved (D-05)"
- "When classification is null (grouped-but-unclassified default state), ActionAreaCard renders an informational note and no action rows/buttons instead of dereferencing recommendedActions — it never crashes on the initial unclassified state"
artifacts:
- path: "components/phishing/action-area-card.tsx"
diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md
index 7fe034c..a8bcce6 100644
--- a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md
+++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md
@@ -13,9 +13,10 @@ requirements: [REVIEW-01, REVIEW-05, REVIEW-06]
must_haves:
truths:
- "Visiting /phishing/tickets/{ticketId} resolves the ticket to its campaign and renders the review page using the existing Better Auth session — no token/query-param auth"
+ - "The review page uses Pulse's full navigation shell — AppNavigation, PageHeader, container mx-auto px-6 py-6 — same chrome as /admin, /analyzer (D-01)"
- "The review page renders classification, action area, evidence, and timeline for a CLASSIFIED grouped campaign; a 'Not yet classified' empty state with a 'Classify this campaign' CTA for a grouped-but-unclassified campaign (the default post-grouping state); a 'Not yet triaged' empty state with an Analyze CTA for D-07; a standalone-report notice + evidence for D-08; and an error+Retry for load failures"
- "A grouped campaign with zero classifications (the default state right after detection/grouping, since classification is never auto-triggered) never crashes — ClassificationCard and ActionAreaCard are not rendered, a Classify CTA (POST /classify) is offered instead, and evidence + timeline still display"
- - "After any approve/remediate/mark-false-positive/classify/analyze succeeds, the page refetches and re-renders from fresh server state"
+ - "After any approve/remediate/mark-false-positive/classify/analyze succeeds, the page refetches and re-renders from fresh server state (D-04)"
- "Visiting /phishing lists recent campaigns and clicking a row navigates to /phishing/tickets/{firstReportTicketId}"
- "A 'Phishing' nav entry links to /phishing"
- "Phase completion is gated on a manual confirmation that a real Autotask LiveLink click resolves against the numeric-ticket-id assumption (or the documented ticket_number fallback is applied)"
diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
new file mode 100644
index 0000000..f6e0813
--- /dev/null
+++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
@@ -0,0 +1,540 @@
+# Phase 22: Approval UI (LiveLink) - Pattern Map
+
+**Mapped:** 2026-07-16
+**Files analyzed:** 14 (new/modified)
+**Analogs found:** 14 / 14
+
+## File Classification
+
+| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
+|---|---|---|---|---|
+| `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` | route (resolver) | request-response | `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` | exact (same param idiom, sibling route family) |
+| `app/api/phishing/campaigns/[id]/route.ts` (EXTEND) | route (controller) | CRUD (read, bulk-fetch) | itself (existing file, additive change) | exact |
+| `app/api/phishing/campaigns/route.ts` (EXTEND) | route (controller) | CRUD (read, list+paginate) | itself (existing file, additive `firstReportTicketId` field) | exact |
+| `lib/services/phishing-ticket-resolver.ts` (NEW, recommended extraction) | service | CRUD (single lookup) | `lib/services/campaign-classifier.ts`'s `gatherCampaignEvidence` (service extraction pattern) + `analyze/route.ts`'s inline query | role-match |
+| `lib/services/remediation-default-params.ts` (NEW, recommended extraction) | utility | transform (pure derivation) | `lib/services/remediation-service.ts`'s `parseRecommendedActions` (small pure-function-in-service-file idiom) | role-match |
+| `app/phishing/page.tsx` | component (page) | CRUD (list + navigate) | `app/admin/data-browser/companies/page.tsx` | exact |
+| `app/phishing/tickets/[ticketId]/page.tsx` | component (page) | request-response (fetch + action POSTs) | `app/analyzer/reports/[id]/page.tsx` | exact |
+| `components/phishing/classification-card.tsx` | component | request-response (read-only render) | `app/analyzer/reports/[id]/page.tsx`'s Card sections (inline, not extracted) | role-match |
+| `components/phishing/action-area-card.tsx` | component | event-driven (checkbox state + POST actions) | `components/rmm/rmm-dispatch-dialog.tsx` | exact (client-side permission gate + fetch-POST-toast pattern) |
+| `components/phishing/evidence-card.tsx` | component | request-response (tabbed read-only render) | `components/admin/DetailModal.tsx` (card+tabs precedent) | role-match |
+| `components/phishing/timeline-card.tsx` | component | transform (client-side merge/sort + render) | none direct — synthesized from `components/ui/status-light.tsx` (dot size token) + plain layout | no analog (new pattern) |
+| `components/phishing/url-list.tsx` | component | transform (inert render + clipboard) | none direct — synthesized from UI-SPEC's explicit spec | no analog (new pattern) |
+| `components/ui/tooltip.tsx` | component (shadcn primitive) | n/a | shadcn official registry (`npx shadcn add tooltip`) | n/a — generated, not hand-written |
+| `components/navigation/app-navigation.tsx` (MODIFY) | config/navigation | n/a (static array edit) | itself (existing `navigationItems` array, PAX8 entry as the shape to copy) | exact |
+
+## Pattern Assignments
+
+### `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` (route, request-response)
+
+**Analog:** `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` (read in full, 91 lines)
+
+**Imports pattern** (lines 1-16 of analog):
+```typescript
+import { NextRequest, NextResponse } from 'next/server';
+import { requirePermission } from '@/lib/auth-utils';
+import postgresClient from '@/lib/services/postgres-client';
+```
+
+**Auth + param-validation pattern** (lines 18-29 of analog):
+```typescript
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ ticket_id: string }> }
+) {
+ const { error } = await requirePermission('phishing', 'read'); // 'read' not 'analyze' for a pure lookup
+ if (error) return error;
+
+ const { ticket_id } = await params;
+ const ticketId = Number(ticket_id);
+ if (!Number.isFinite(ticketId)) {
+ return NextResponse.json({ error: 'Invalid ticket_id' }, { status: 400 });
+ }
+```
+Use `GET` not `POST` for this new route (pure read, no side effect) — same signature shape otherwise.
+
+**Core lookup pattern (new, D-07/D-08 aware)** — mirrors the analog's `postgresClient.query` + row-shape idiom but simpler (single table, no joins needed beyond `reports`):
+```typescript
+const res = await postgresClient.query<{ id: string; campaign_id: string | null; ticket_number: string | null }>(
+ `SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1`,
+ [ticketId]
+);
+const report = res.rows[0];
+if (!report) {
+ // D-07: 200 + found:false, NOT a 404 — see analog's own 400 (bad input) vs
+ // this route's deliberate 200 (valid input, no data yet) distinction.
+ return NextResponse.json({ found: false }, { status: 200 });
+}
+return NextResponse.json({
+ found: true,
+ reportId: report.id,
+ campaignId: report.campaign_id, // null => D-08 ungrouped-report path
+ ticketNumber: report.ticket_number,
+});
+```
+
+**Error handling pattern** (lines 83-89 of analog, copy verbatim structure):
+```typescript
+} catch (err) {
+ console.error('[PHISHING-TICKET-CAMPAIGN] Failed to resolve ticket->campaign', ticketId, err);
+ return NextResponse.json(
+ { error: 'Failed to resolve ticket', message: err instanceof Error ? err.message : 'Unknown error' },
+ { status: 500 }
+ );
+}
+```
+
+---
+
+### `app/api/phishing/campaigns/[id]/route.ts` (EXTEND) (route, CRUD)
+
+**Analog:** itself — `app/api/phishing/campaigns/[id]/route.ts` (read in full, 174 lines). This is an in-place additive extension, not a new file.
+
+**Existing bulk-fetch idiom to replicate for each new field** (lines 84-124 — the `reportsRes`/`messagesRes`/`indicatorsRes`/`classificationsRes` pattern: query by `campaign_id = $1` or `ANY($1::uuid[])`, then `.map()` into camelCase):
+```typescript
+const reportsRes = 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.campaign_id = $1
+ ORDER BY r.created_at ASC`,
+ [id]
+);
+```
+
+**New field 1 — `remediationActions`** (add alongside the existing `classificationsRes` query, same file/section):
+```typescript
+const remediationRes = await postgresClient.query<{
+ id: string; action_type: string; status: string; params: unknown;
+ approved_by: string | null; approved_at: string | null;
+}>(
+ `SELECT id::text, action_type, status, params, approved_by, approved_at::text
+ FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`,
+ [id]
+);
+```
+
+**New field 2 — `auditEvents`**:
+```typescript
+const auditRes = await postgresClient.query<{
+ id: string; actor: string | null; event_type: string; payload: unknown; created_at: string;
+}>(
+ `SELECT id::text, actor, event_type, payload, created_at::text
+ FROM audit_events WHERE campaign_id = $1 ORDER BY created_at ASC`,
+ [id]
+);
+```
+
+**New field 3 — extend the existing `classifications` query** (widen the existing `SELECT` at lines 120-124, adding columns already on the table — no schema change):
+```typescript
+`SELECT id::text, verdict, confidence, summary, reasons, recommended_actions,
+ requires_approval, created_at::text
+ FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC`
+```
+
+**New field 4 — `blastRadius`, fresh every request.** Copy the sender/recipient derivation from `lib/services/campaign-classifier.ts`'s `gatherCampaignEvidence` (lines 331-345, read in full) — NOT `triage-note-service.ts`'s empty-string call:
+```typescript
+// Source: lib/services/campaign-classifier.ts lines 331-345 (gatherCampaignEvidence)
+const primaryReport = reports[0] ?? null;
+let blastRadius: BlastRadiusResult;
+if (primaryReport) {
+ const primaryMessage = messages.find((m) => m.reportId === primaryReport.id) ?? null;
+ const senderIndicator = indicators.find(
+ (i) => i.messageId === primaryMessage?.id && i.indicatorType === 'sender'
+ );
+ blastRadius = await getBlastRadius({
+ sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '',
+ recipient: primaryReport.requesterEmail ?? '',
+ subject: primaryMessage?.subject ?? primaryReport.title ?? '',
+ dateWindow: { start: /* derive from primaryReport.createdAt */ new Date(...), end: new Date() },
+ });
+} else {
+ blastRadius = { status: 'unavailable', reason: 'not_configured' };
+}
+```
+Import: `import { getBlastRadius, type BlastRadiusResult } from '@/lib/services/mimecast-blast-radius';`
+
+**Derived `completedAt` for remediation_actions (Pitfall 2/Pattern 3, no schema change)**:
+```typescript
+const completedAtByActionId = new Map();
+for (const event of auditRes.rows) {
+ if (event.event_type === 'remediation_completed') {
+ const actionId = (event.payload as { actionId?: string })?.actionId;
+ if (actionId) completedAtByActionId.set(actionId, event.created_at);
+ }
+}
+// remediationActions: remediationRes.rows.map(a => ({ ...camelCased, completedAt: completedAtByActionId.get(a.id) ?? null }))
+```
+
+**Error handling** (lines 166-172 of the existing file, unchanged — do not alter):
+```typescript
+} catch (err) {
+ console.error('[PHISHING-CAMPAIGN-DETAIL] Failed to load campaign', id, err);
+ return NextResponse.json(
+ { error: 'Failed to load campaign', message: err instanceof Error ? err.message : 'Unknown error' },
+ { status: 500 }
+ );
+}
+```
+
+---
+
+### `app/api/phishing/campaigns/route.ts` (EXTEND) (route, CRUD)
+
+**Analog:** itself — read in full (86 lines). Add one `LEFT JOIN LATERAL` (or subquery) for `firstReportTicketId` inside the existing `campaigns.rows.map()` at lines 66-75:
+```typescript
+// Existing SELECT (lines 43-51) gets one additive column:
+`SELECT c.id::text, c.campaign_key, c.group_method, c.first_seen_at::text, c.last_seen_at::text,
+ c.report_count, c.status, c.created_at::text,
+ (SELECT r.ticket_id::text FROM reports r WHERE r.campaign_id = c.id ORDER BY r.created_at ASC LIMIT 1) AS first_report_ticket_id
+ FROM campaigns c
+ ${statusFilter}
+ ORDER BY c.last_seen_at DESC NULLS LAST
+ LIMIT $1 OFFSET $2`
+```
+Then add `firstReportTicketId: c.first_report_ticket_id` to the existing `items.map()` (line 66-75) — purely additive, no existing field touched.
+
+---
+
+### `lib/services/phishing-ticket-resolver.ts` (NEW — recommended extraction for testability)
+
+**Analog:** No direct extracted-service analog exists for this exact shape; follow the general "small pure service function wrapping a `postgresClient.query`" idiom used throughout `lib/services/campaign-classifier.ts` and `lib/services/remediation-service.ts` (both: plain exported `async function`, typed row interface, `postgresClient` import, no class).
+
+**Pattern to copy** (shape, from `remediation-service.ts`'s top-level exported functions):
+```typescript
+import { postgresClient } from './postgres-client';
+
+export interface TicketCampaignResolution {
+ found: boolean;
+ reportId?: string;
+ campaignId?: string | null;
+ ticketNumber?: string | null;
+}
+
+export async function resolveTicketToCampaign(ticketId: number): Promise {
+ const res = await postgresClient.query<{ id: string; campaign_id: string | null; ticket_number: string | null }>(
+ `SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1`,
+ [ticketId]
+ );
+ const report = res.rows[0];
+ if (!report) return { found: false };
+ return { found: true, reportId: report.id, campaignId: report.campaign_id, ticketNumber: report.ticket_number };
+}
+```
+The route then becomes a thin `requirePermission` + param-parse + call wrapper (Wave 0 test recommendation from RESEARCH.md: `npx vitest run lib/services/phishing-ticket-resolver.test.ts`, matches `vitest.config.ts`'s `lib/**/*.test.ts` include pattern).
+
+---
+
+### `lib/services/remediation-default-params.ts` (NEW — recommended extraction for testability)
+
+**Analog:** `lib/services/remediation-service.ts`'s `parseRecommendedActions` (lines 63-75) — small, pure, exported-adjacent helper function pattern; also `campaign-classifier.ts`'s `mapVerdictToActions` (exhaustive-switch-like mapping) as the shape for the 7-action lookup table.
+
+**Pattern to copy** (pure function signature + switch, from `mapVerdictToActions`):
+```typescript
+// Source shape: lib/services/campaign-classifier.ts mapVerdictToActions()
+export function deriveDefaultParams(
+ actionType: string,
+ evidence: { requesterEmail: string | null; senderEmail: string | null; senderDomain: string | null; messageId: string | null }
+): Record {
+ switch (actionType) {
+ case 'no_action': return {};
+ case 'warn_user': return { recipientEmail: evidence.requesterEmail ?? '', message: '' };
+ case 'block_sender': return { senderEmail: evidence.senderEmail ?? '', senderDomain: evidence.senderDomain ?? '' };
+ case 'purge_message': return { messageId: evidence.messageId ?? '', mailboxes: [] };
+ case 'reset_password': return { userPrincipalName: evidence.requesterEmail ?? '' };
+ case 'isolate_endpoint': return { deviceId: '' };
+ case 'disable_forwarding_rule': return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' };
+ default: return {};
+ }
+}
+```
+Exact 7-row table is fully specified in UI-SPEC's Action Area Spec — this is a pure transform, unit-testable per RESEARCH.md's Wave 0 gap (`npx vitest run lib/services/remediation-default-params.test.ts`).
+
+---
+
+### `app/phishing/page.tsx` (component/page, CRUD list)
+
+**Analog:** `app/admin/data-browser/companies/page.tsx` (read lines 1-90) — closest existing `'use client'` + `DataTable` + pagination-state + row-click-navigate pattern in the codebase, even though it's an admin data-browser page rather than a phishing-specific one.
+
+**Imports pattern** (lines 1-10 of analog):
+```typescript
+'use client';
+import { useState, useEffect } from 'react';
+import DataTable from '@/components/admin/DataTable';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import Link from 'next/link';
+```
+Add: `AppNavigation`/`PageHeader` are supplied by the root layout + `components/navigation/page-header.tsx` — import `PageHeader` from `@/components/navigation/page-header` directly, per UI-SPEC D-01.
+
+**State + fetch pattern** (lines 38-73 of analog — adapt `fetchCompanies` to call `/api/phishing/campaigns`):
+```typescript
+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(() => { fetchCampaigns(page); }, [page]);
+```
+
+**Row-click navigation** (lines 75-78 of analog, adapted per UI-SPEC — navigate, don't open a modal):
+```typescript
+const router = useRouter(); // next/navigation
+const handleRowClick = (campaign: any) => {
+ router.push(`/phishing/tickets/${campaign.firstReportTicketId}`);
+};
+```
+
+**DataTable usage + empty state** — `components/admin/DataTable.tsx` confirmed props (`columns`, `data`, `totalCount`, `page`, `pageSize`, `onPageChange`, `onRowClick`); wrap in `EmptyState` (`components/ui/empty-state.tsx`, read in full) when `items.length === 0`:
+```typescript
+
+```
+
+---
+
+### `app/phishing/tickets/[ticketId]/page.tsx` (component/page, request-response)
+
+**Analog:** `app/analyzer/reports/[id]/page.tsx` (read lines 1-170) — closest existing `'use client'` + `use(params)` + polling-capable fetch + Card-stack layout + loading/error early-return pattern.
+
+**Imports + params pattern** (lines 1-16 of analog):
+```typescript
+'use client';
+import { useEffect, useState, use } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { toast } from 'sonner';
+import { useSession } from '@/lib/auth-client';
+import { hasPermission } from '@/lib/permissions';
+
+export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) {
+ const { ticketId } = use(params);
+```
+
+**Fetch + loading/error state pattern** (lines 129-166 of analog — adapt to the two-step fetch: resolver then campaign detail):
+```typescript
+const [state, setState] = useState<'loading' | 'not-triaged' | 'ungrouped' | 'ready' | 'error'>('loading');
+const [campaign, setCampaign] = useState(null);
+const [error, setError] = useState(null);
+
+async function load() {
+ setState('loading');
+ try {
+ const resolveRes = await fetch(`/api/phishing/tickets/${ticketId}/campaign`);
+ const resolved = await resolveRes.json();
+ if (!resolved.found) { setState('not-triaged'); return; }
+ if (!resolved.campaignId) { setState('ungrouped'); /* fetch report-only evidence */ return; }
+ const detailRes = await fetch(`/api/phishing/campaigns/${resolved.campaignId}`);
+ if (!detailRes.ok) throw new Error(`Request failed: ${detailRes.status}`);
+ const data = await detailRes.json();
+ setCampaign(data);
+ setState('ready');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ setState('error');
+ }
+}
+useEffect(() => { void load(); }, [ticketId]);
+```
+D-04's "refetch after action" reuses this same `load()` function — pass it down to `ActionAreaCard` as an `onActionComplete` callback.
+
+**Client-side permission gate** (no existing call site — first instance in codebase; synthesized from `components/rmm/rmm-dispatch-dialog.tsx` lines 50-52 `useSession()` shape + confirmed `hasPermission()` signature in `lib/permissions.ts`):
+```typescript
+const { data: session } = useSession();
+const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
+const canApprove = hasPermission(role, 'phishing', 'approve');
+const canRemediate = hasPermission(role, 'phishing', 'remediate');
+```
+
+**Loading/error early-return pattern** (lines 162-166 of analog, adapted to `SkeletonHeader`+`SkeletonCard` per UI-SPEC rather than plain text):
+```typescript
+if (state === 'loading') return <> >;
+if (state === 'error') return {error} ;
+```
+
+---
+
+### `components/phishing/action-area-card.tsx` (component, event-driven)
+
+**Analog:** `components/rmm/rmm-dispatch-dialog.tsx` (read in full, 190 lines) — closest existing client-side permission-gated dispatch-and-toast component.
+
+**Permission + disabled-button pattern** (lines 50-52, 108-112, 125-134 of analog):
+```typescript
+const { data: session } = useSession();
+const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
+const canExecute = role === 'admin' || role === 'super-admin'; // replace with hasPermission() per D-06
+
+const disabledReason = !canExecute ? 'Requires admin' : !deviceUid ? 'No Datto device id' : null;
+
+