docs(22): create phase plan
This commit is contained in:
parent
476eb017eb
commit
4b5e31c068
5 changed files with 558 additions and 16 deletions
|
|
@ -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
|
||||
</content>
|
||||
|
|
|
|||
|
|
@ -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 <pre>, 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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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<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.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<string, string>();
|
||||
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<TicketCampaignResolution> {
|
||||
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<string, unknown> {
|
||||
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
|
||||
<EmptyState icon={ShieldAlert} title="No campaigns yet" description="Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message." />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `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<CampaignDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <><SkeletonHeader /><SkeletonCard /><SkeletonCard /><SkeletonCard /></>;
|
||||
if (state === 'error') return <Alert variant="destructive">{error} <Button onClick={load}>Retry</Button></Alert>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `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;
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!!disabledReason}
|
||||
title={disabledReason ?? 'Dispatch a Datto RMM script for this device'}
|
||||
>
|
||||
```
|
||||
Replace `title` attribute with the new shadcn `Tooltip` primitive per UI-SPEC (D-05/D-06 requires a `Tooltip`, not a bare `title` — this is the one place this phase's pattern diverges from the analog).
|
||||
|
||||
**Fetch-POST-toast pattern** (lines 77-105 of analog — copy structure exactly for approve/remediate/mark-false-positive):
|
||||
```typescript
|
||||
async function dispatch() {
|
||||
try {
|
||||
const res = await fetch(`/api/phishing/campaigns/${campaignId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actions: checkedActions }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Approve failed');
|
||||
toast.success(`Approved ${data.length} remediation action(s)`);
|
||||
onActionComplete(); // D-04: refetch, no optimistic mutation
|
||||
} catch (err) {
|
||||
toast.error(`Approve failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request body shape (must match exactly)** — from `lib/services/remediation-service.ts` `ApproveActionInput`:
|
||||
```typescript
|
||||
interface ApproveActionInput { actionType: string; params?: Record<string, unknown>; }
|
||||
// POST body: { actions: ApproveActionInput[] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `components/phishing/classification-card.tsx` / `evidence-card.tsx` / `timeline-card.tsx` (components, read-only render)
|
||||
|
||||
**Analog:** `app/analyzer/reports/[id]/page.tsx`'s inline Card sections (lines 169-277+, e.g. the Categories/Root cause cards at lines 261-276) — the `<Card><CardHeader><CardTitle>...</CardTitle></CardHeader><CardContent>...</CardContent></Card>` nesting idiom, plus `MiniBars`-style small presentational sub-components (lines 98-120).
|
||||
|
||||
**Card shell pattern** (lines 261-268 of analog):
|
||||
```typescript
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-bold">{/* explicit font-bold override per UI-SPEC Typography note — CardTitle defaults to font-semibold */}
|
||||
<Sparkles className="h-4 w-4 mr-2 inline" /> Classification
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* content */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**Tabs pattern for `evidence-card.tsx`** — no existing phishing tabs precedent; use `components/ui/tabs.tsx` primitive directly (already in the repo, confirmed by UI-SPEC's Component Inventory) with the same `<Tabs><TabsList><TabsTrigger>` shadcn idiom used elsewhere (e.g. search `grep -rl "TabsTrigger" components/`); no phishing-specific analog needed since this is a direct shadcn composition, not a bespoke pattern.
|
||||
|
||||
**StatusBadge usage for verdict/SPF/DKIM/etc.** — `components/ui/status-badge.tsx` (read in full, 76 lines):
|
||||
```typescript
|
||||
<StatusBadge variantClass="bg-destructive/15 text-destructive">THREAT</StatusBadge>
|
||||
// or tone-based:
|
||||
<StatusBadge tone="success">pass</StatusBadge>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `components/phishing/url-list.tsx` (component, D-09 inert render)
|
||||
|
||||
**No direct analog** — this is new codebase-first territory (no existing "extracted URL, inert" component exists). Build directly from UI-SPEC's Evidence Display Spec §2 (exact code given there):
|
||||
```typescript
|
||||
<code className="font-mono text-xs truncate">{url}</code>
|
||||
<Button size="icon" variant="ghost" aria-label="Copy URL" onClick={() => { navigator.clipboard.writeText(url); toast.success('Copied'); }}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
```
|
||||
**Critical constraint (D-09):** never wrap in `<a>`/`href`, never attach an `onClick` that navigates — copy-to-clipboard only. This is the one component where "no existing analog" is itself the point: `triage-note-service.ts`'s sanitization approach (strips query strings, still describes in prose) must NOT be copied here — this is stricter (fully inert).
|
||||
|
||||
---
|
||||
|
||||
### `components/navigation/app-navigation.tsx` (MODIFY)
|
||||
|
||||
**Analog:** itself — the existing `navigationItems` array (lines 51-202, read in full). The `PAX8` entry (lines 64-69) is the exact shape to copy per UI-SPEC's explicit instruction (flat top-level item, no children):
|
||||
```typescript
|
||||
{
|
||||
title: 'PAX8',
|
||||
href: '/pax8',
|
||||
icon: ShoppingCart,
|
||||
description: 'PAX8 companies, subscriptions, and cost breakdown'
|
||||
},
|
||||
```
|
||||
New entry (insert immediately after PAX8, before "Backup Status", per UI-SPEC D-02):
|
||||
```typescript
|
||||
{
|
||||
title: 'Phishing',
|
||||
href: '/phishing',
|
||||
icon: ShieldAlert,
|
||||
description: 'Phishing/spam campaign triage, evidence, and remediation approval',
|
||||
},
|
||||
```
|
||||
Add `ShieldAlert` to the existing `lucide-react` import block (line 6-22). No entry needed in `visibleItems` role-filter (lines 219-224) — Phishing is visible to all roles (every role has `phishing: ["read"]` minimum), unlike Engagement/Admin which are super-admin-gated.
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Auth (server-side, every new/extended route)
|
||||
**Source:** `lib/auth-utils.ts`'s `requirePermission`, used identically across all 6 existing phishing routes (`campaigns/[id]/route.ts` line 62, `approve/route.ts` line 28, `remediate/route.ts` line 27, `mark-false-positive/route.ts` line 27, `classify/route.ts` line 24, `analyze/route.ts` line 22).
|
||||
**Apply to:** the new ticket→campaign resolver route (`requirePermission('phishing', 'read')`), no other new routes needed (approve/remediate/mark-false-positive/classify already exist and are reused verbatim).
|
||||
```typescript
|
||||
const { error } = await requirePermission('phishing', 'read');
|
||||
if (error) return error;
|
||||
```
|
||||
|
||||
### UUID validation (every `[id]`-scoped route)
|
||||
**Source:** identical `UUID_RE` constant duplicated verbatim across all 5 existing `campaigns/[id]/**` routes:
|
||||
```typescript
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
```
|
||||
**Apply to:** No new `[id]`-scoped route is introduced this phase (the campaign detail route is extended, not new) — this pattern is reference-only for consistency-checking the extension didn't break it. The new ticket resolver route uses `Number.isFinite(ticketId)` instead (matches `analyze/route.ts`'s idiom, not the UUID one).
|
||||
|
||||
### Error handling (every route in this phase)
|
||||
**Source:** identical `try/catch` + `console.error('[TAG] message', id, err)` + `NextResponse.json({ error, message }, { status: 500 })` shape, verbatim across all 6 read routes.
|
||||
**Apply to:** the new resolver route, and the extended detail/list routes (do not change the existing catch blocks — only widen the `try` body).
|
||||
|
||||
### Client-side permission gating (D-06/REVIEW-06) — first instance in this codebase
|
||||
**Source:** synthesized from `lib/permissions.ts`'s confirmed-pure `hasPermission(roleName, resource, action)` + `components/rmm/rmm-dispatch-dialog.tsx`'s confirmed `useSession()` usage (lines 50-52). **No single file contains this exact call today** — this phase establishes the pattern.
|
||||
**Apply to:** `app/phishing/tickets/[ticketId]/page.tsx`, `components/phishing/action-area-card.tsx`, `components/phishing/classification-card.tsx` (Reclassify button).
|
||||
```typescript
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { hasPermission } from '@/lib/permissions';
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
||||
const canApprove = hasPermission(role, 'phishing', 'approve');
|
||||
```
|
||||
|
||||
### Refetch-after-action, no optimistic mutation (D-04)
|
||||
**Source:** No existing exact analog (most write-then-refetch flows in this codebase use React Query-style invalidation, absent here) — established directly by CONTEXT.md D-04. Structurally similar to `app/analyzer/reports/[id]/page.tsx`'s own re-poll-on-timer pattern (lines 146-148: `if (report.status === 'pending' ...) timer = setTimeout(load, 3000)`), reused here as "call `load()` again after a 2xx POST" rather than on a timer.
|
||||
**Apply to:** all three action buttons in `action-area-card.tsx`.
|
||||
|
||||
### Toast feedback
|
||||
**Source:** `sonner`'s `toast.success()`/`toast.error()`, used identically in `components/rmm/rmm-dispatch-dialog.tsx` lines 99, 101.
|
||||
**Apply to:** all three action buttons, plus the D-07 "Analyze this ticket" CTA and the Reclassify button.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `components/phishing/timeline-card.tsx` | component | transform (merge 3 sources + render) | No existing "vertical timeline merging 3 heterogeneous sources" component in Pulse — UI-SPEC prescribes plain `div`s + Tailwind (explicitly "not a reusable pattern elsewhere in Pulse yet"), build fresh per the UI-SPEC's Timeline Spec section. |
|
||||
| `components/phishing/url-list.tsx` | component | transform (inert render) | No existing "extracted-URL-as-inert-text" component; D-09 is a deliberately stricter posture than any existing sanitization precedent (Phase 21's triage-note prose sanitization). Build fresh from UI-SPEC's exact code block. |
|
||||
| `components/ui/tooltip.tsx` | component (shadcn primitive) | n/a | Confirmed absent from `components/ui/` via grep — generate via `npx shadcn add tooltip`, not hand-written; no "analog" applies to a registry-generated file. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `app/api/phishing/**`, `lib/services/*.ts` (phishing/mimecast/triage-note/remediation/audit/classifier), `lib/permissions.ts`, `lib/auth-client.ts`, `lib/auth-utils.ts`, `components/navigation/**`, `components/rmm/rmm-dispatch-dialog.tsx`, `components/admin/DataTable.tsx`, `components/ui/{status-badge,empty-state,skeleton-helpers,card}.tsx`, `app/analyzer/reports/[id]/page.tsx`, `app/admin/data-browser/companies/page.tsx`
|
||||
**Files scanned:** ~24 read in full or targeted ranges
|
||||
**Pattern extraction date:** 2026-07-16
|
||||
Loading…
Add table
Add a link
Reference in a new issue