diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 5a497cc..c0ad56e 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -503,7 +503,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
| 19. Classification Engine | v3.0 | 2/2 | Complete | 2026-07-16 |
| 20. Remediation, Approval & Audit Safety | v3.0 | 2/2 | Complete | 2026-07-16 |
| 21. Autotask Triage Note | v3.0 | 2/2 | Complete | 2026-07-16 |
-| 22. Approval UI (LiveLink) | v3.0 | 0/TBD | Not started | - |
+| 22. Approval UI (LiveLink) | v3.0 | 0/6 | Not started | - |
### Phase 22: Approval UI (LiveLink)
**Goal**: A security operator opens an Autotask ticket, clicks a LiveLink button, and lands on a Pulse page scoped to that ticket showing the campaign's timeline, evidence, and classification — with approve/remediate/mark-false-positive actions right there, so no one is calling the Phase 20 APIs by hand.
@@ -516,7 +516,13 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
4. The page shows the current classification (SPAM/UNWANTED/THREAT), confidence, reasons, and recommended remediation action(s) from Phase 19
5. Approve, remediate, and mark-false-positive buttons call the Phase 20 APIs directly from the page and reflect the resulting state (e.g. a remediated campaign shows as remediated, not re-offered for approval)
6. An operator without the elevated permission REMED-02/ACCESS-01 already require sees the approve/remediate actions disabled or hidden rather than a failed request; the page never uses a relaxed or separate permission check from the underlying APIs
-**Plans**: TBD
+**Plans**: 6 plans
+- [ ] 22-01-PLAN.md — Pure testable logic: ticket->campaign resolver, 7-action default-params, timeline merge (REVIEW-01, REVIEW-02, REVIEW-04)
+- [ ] 22-02-PLAN.md — Backend routes: new ticket->campaign resolver + extend campaign-detail (evidence/timeline/classification/blast radius) + list firstReportTicketId (REVIEW-01..04)
+- [ ] 22-03-PLAN.md — Evidence display: shadcn tooltip + inert UrlList (D-09) + tabbed EvidenceCard (REVIEW-03)
+- [ ] 22-04-PLAN.md — ClassificationCard + TimelineCard (REVIEW-02, REVIEW-04)
+- [ ] 22-05-PLAN.md — ActionAreaCard: approve/remediate/mark-false-positive with server-identical permission gating (REVIEW-05, REVIEW-06)
+- [ ] 22-06-PLAN.md — Review page + campaigns list page + nav entry (REVIEW-01, REVIEW-05, REVIEW-06)
**UI hint**: yes
---
diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-PLAN.md
new file mode 100644
index 0000000..f5d182f
--- /dev/null
+++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-PLAN.md
@@ -0,0 +1,191 @@
+---
+phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - lib/services/phishing-ticket-resolver.ts
+ - lib/services/phishing-ticket-resolver.test.ts
+ - lib/services/remediation-default-params.ts
+ - lib/services/remediation-default-params.test.ts
+ - lib/services/phishing-timeline.ts
+ - lib/services/phishing-timeline.test.ts
+autonomous: true
+requirements: [REVIEW-01, REVIEW-02, REVIEW-04]
+must_haves:
+ truths:
+ - "Given a ticket id with no reports row, the resolver returns { found: false }"
+ - "Given a ticket id whose report has a null campaign_id, the resolver returns { found: true, campaignId: null }"
+ - "Given a ticket id whose report is grouped, the resolver returns { found: true, reportId, campaignId, ticketNumber }"
+ - "deriveDefaultParams returns the exact param object for each of the 7 action types"
+ - "mergeTimeline returns a single array of report/classification/audit entries sorted ascending by timestamp"
+ artifacts:
+ - path: "lib/services/phishing-ticket-resolver.ts"
+ provides: "resolveTicketToCampaign(ticketId) DB lookup"
+ exports: ["resolveTicketToCampaign", "TicketCampaignResolution"]
+ - path: "lib/services/remediation-default-params.ts"
+ provides: "7-action-type default-param derivation"
+ exports: ["deriveDefaultParams", "DefaultParamEvidence"]
+ - path: "lib/services/phishing-timeline.ts"
+ provides: "chronological merge of reports + classifications + audit events"
+ exports: ["mergeTimeline", "TimelineEntry"]
+ key_links:
+ - from: "lib/services/phishing-ticket-resolver.ts"
+ to: "reports table"
+ via: "postgresClient.query SELECT ... FROM reports WHERE ticket_id = $1"
+ pattern: "FROM reports WHERE ticket_id"
+---
+
+
, never via dangerouslySetInnerHTML" + - "Blast radius renders an explicit 'unavailable' state when status is unavailable, and counts + per-recipient table when ok" + artifacts: + - path: "components/ui/tooltip.tsx" + provides: "shadcn tooltip primitive (for later disabled-button explanations)" + - path: "components/phishing/url-list.tsx" + provides: "inert copy-only URL list (D-09)" + exports: ["UrlList"] + - path: "components/phishing/evidence-card.tsx" + provides: "tabbed EML evidence display (REVIEW-03)" + exports: ["EvidenceCard"] + key_links: + - from: "components/phishing/url-list.tsx" + to: "navigator.clipboard" + via: "writeText on copy button click" + pattern: "navigator.clipboard.writeText" +--- + ++Build the read-only evidence surface for the review page: the shadcn `tooltip` primitive (needed later by the action card), the inert copy-only `UrlList` component enforcing D-09's stricter-than-sanitization posture, and the tabbed `EvidenceCard` rendering parsed EML headers, URLs, attachments, body preview, and Mimecast blast radius (REVIEW-03). + +Purpose: This is the phase's biggest XSS-exposure surface (attacker-controlled email content). It is built as a pure presentational component so the review page (plan 06) composes it without embedding rendering logic. +Output: 1 shadcn primitive + 2 phishing components. + + ++@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + ++@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md + + + ++messages evidence shape (from the extended detail route, plan 02): + headers: { from:{displayName,email,domain}; replyTo; returnPath; to:string[]; cc:string[]; subject; date; messageId; receivedChain:string[]; authResults:{spf?;dkim?;dmarc?}; authResultsOriginal:{...}|null } + urls: string[] + attachments: Array<{ filename:string|null; contentType:string|null; size:number; checksum:string|null; related:boolean }> + bodyPreview: string (plain text, ≤500 chars) + +blastRadius (from detail route): + { status:'unavailable'; reason:'not_configured'|'lookup_failed'; error? } + | { status:'ok'; matched; delivered; held; rejected; clicked; perRecipient: Array<{recipient; status:'delivered'|'held'|'rejected'|'unknown'}> } + +Reused primitives (do not modify): components/ui/{tabs,table,accordion,alert,card,select,status-badge}.tsx, sonner toast, lucide-react (Copy). + ++ + + ++ + +Task 1: Add tooltip primitive + build UrlList (D-09 inert) +components/ui/tooltip.tsx, components/phishing/url-list.tsx ++ - components/ui/status-badge.tsx (existing primitive prop shape + cn() styling convention) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Evidence Display Spec §2 — exact url-list markup + copy affordance) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "components/phishing/url-list.tsx") + ++ First run `npx shadcn add tooltip` (official registry, generates `components/ui/tooltip.tsx` — no npm dependency added; if the CLI prompts, accept defaults). Then create `components/phishing/url-list.tsx` as a `'use client'` component exporting `UrlList({ urls }: { urls: string[] })`. Render each URL as ` +{url}` followed by an icon-only copy button (`Button size="icon" variant="ghost"`, lucide `Copy` at `h-3.5 w-3.5`, `aria-label="Copy URL"`) whose onClick calls `navigator.clipboard.writeText(url)` then `toast.success('Copied')`. CRITICAL (D-09): never wrap a URL in ``/`href`, never attach a navigating onClick, never use `` — copy-to-clipboard only. When `urls.length === 0`, render inline text "No URLs found in this message." (size sm, muted), NOT the full EmptyState component. ++ +test -f components/ui/tooltip.tsx && grep -q "navigator.clipboard.writeText" components/phishing/url-list.tsx && grep -Lq "href" components/phishing/url-list.tsx && echo OK ++ - `components/ui/tooltip.tsx` exists and exports Tooltip/TooltipTrigger/TooltipContent/TooltipProvider + - `grep -q "navigator.clipboard.writeText" components/phishing/url-list.tsx` succeeds + - `grep -c "href" components/phishing/url-list.tsx` returns 0 (no href anywhere) + - `grep -q "aria-label=\"Copy URL\"" components/phishing/url-list.tsx` succeeds + - No new dependency added to package.json (git diff of package.json is empty) + ++ + +Task 2: EvidenceCard — tabbed EML evidence (REVIEW-03) +components/phishing/evidence-card.tsx ++ - components/admin/DetailModal.tsx (card + tabs formatted/raw precedent) + - components/ui/status-badge.tsx (StatusBadge prop shapes for SPF/DKIM/DMARC + blast-radius status badges) + - components/phishing/url-list.tsx (task 1 — consumed here) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Evidence Display Spec §1-5, Color table for badge recipes) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section for classification/evidence/timeline components) + ++ Create `components/phishing/evidence-card.tsx` (`'use client'`) exporting `EvidenceCard({ messages, blastRadius })` where `messages` is the extended detail route's message array (each with headers/urls/attachments/bodyPreview + ticketNumber-bearing report linkage) and `blastRadius` is the BlastRadiusResult. Wrap in a shadcn ` +` with ` ` (explicit font-bold override — CardTitle defaults to font-semibold) and a leading lucide `FileText` icon (`h-4 w-4 mr-2 inline`). If more than one message exists, render an outer ` + +grep -q "whitespace-pre-wrap" components/phishing/evidence-card.tsx && ! grep -q "dangerouslySetInnerHTML" components/phishing/evidence-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "evidence-card" | grep -qx 0 && echo OK ++ - `grep -c "dangerouslySetInnerHTML" components/phishing/evidence-card.tsx` returns 0 + - `grep -q "whitespace-pre-wrap" components/phishing/evidence-card.tsx` succeeds (body preview in a pre) + - `grep -q "UrlList" components/phishing/evidence-card.tsx` succeeds (URLs tab delegates to the inert component) + - `grep -q "status === 'unavailable'" components/phishing/evidence-card.tsx` (or equivalent) — explicit unavailable branch present + - `grep -q "font-bold" components/phishing/evidence-card.tsx` succeeds (CardTitle override) + - `npx tsc --noEmit --pretty` reports no error in this file + ++## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| attacker-controlled email content → browser DOM | headers, urls, attachment filenames/hashes, and body preview all originate from a reported (potentially malicious) email | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-22-07 | Tampering / Information Disclosure (stored XSS via body preview) | evidence-card.tsx | mitigate | Body preview rendered only inside ` + +` as JSX text; zero `dangerouslySetInnerHTML` anywhere (React auto-escapes text) | +| T-22-08 | Tampering (clickjacking / drive-by via malicious URL click) | url-list.tsx | mitigate | D-09 — extracted URLs rendered as inert `` text with copy-to-clipboard only; no ``, no navigating onClick, no `` | +| T-22-09 | Information Disclosure (rendering raw attachment content) | evidence-card.tsx Attachments tab | mitigate | Only filename/content-type/size/hash metadata rendered; no file content is fetched or displayed (EVID-04) | +| T-22-SC | Tampering | npm/pip/cargo installs | accept | `npx shadcn add tooltip` generates local Radix source from the official registry — not an npm package install; per 22-RESEARCH Package Legitimacy Audit the gate does not apply | ++- `npx tsc --noEmit --pretty` clean +- `npm test` still green (no component tests under vitest config) +- Manual: render evidence card with a fixture message and confirm no clickable URLs and plain-text body + + ++Evidence display renders headers/URLs/attachments/body/blast-radius safely: URLs inert copy-only, body plain-text, blast-radius unavailable state explicit. + + + diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-PLAN.md new file mode 100644 index 0000000..0845d1c --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-PLAN.md @@ -0,0 +1,146 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 04 +type: execute +wave: 1 +depends_on: [] +files_modified: + - components/phishing/classification-card.tsx + - components/phishing/timeline-card.tsx +autonomous: true +requirements: [REVIEW-02, REVIEW-04] +must_haves: + truths: + - "ClassificationCard shows the latest verdict (SPAM/UNWANTED/THREAT), confidence, summary, reasons list, and recommended action chips" + - "ClassificationCard shows a requires-approval warning Alert when requiresApproval is true" + - "TimelineCard renders a single chronological list merging linked reports, classification history, and audit events" + artifacts: + - path: "components/phishing/classification-card.tsx" + provides: "read-only classification display (REVIEW-04)" + exports: ["ClassificationCard"] + - path: "components/phishing/timeline-card.tsx" + provides: "chronological timeline renderer (REVIEW-02)" + exports: ["TimelineCard"] + key_links: + - from: "components/phishing/timeline-card.tsx" + to: "TimelineEntry (from plan 02 detail-route timeline)" + via: "renders entry.kind discriminant" + pattern: "kind" +--- + ++Build the two remaining read-only display components: `ClassificationCard` (REVIEW-04 — latest verdict/confidence/reasons/recommended actions, with a destructive-action approval warning) and `TimelineCard` (REVIEW-02 — a single chronological list rendering the pre-merged timeline array from the extended detail route). + +Purpose: These are pure presentational renderers of data the plan-02 route already shapes; keeping them separate from the interactive action card keeps each component focused. +Output: 2 phishing components. + + ++@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + ++@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md + + + ++classification (latest row, from detail route): + { id; verdict:'SPAM'|'UNWANTED'|'THREAT'; confidence:string|null; summary:string|null; reasons:string[]; recommendedActions:string[]; requiresApproval:boolean; createdAt } + +timeline (from detail route, plan 02 — TimelineEntry[]): + { kind:'report'; at; reportId; ticketNumber; companyName } + | { kind:'classification'; at; verdict; confidence } + | { kind:'audit'; at; eventType; actor; payload } + +Verdict badge recipes (UI-SPEC Color table): + SPAM=bg-slate-500/15 text-slate-600 · UNWANTED=bg-amber-500/15 text-amber-600 · THREAT=bg-destructive/15 text-destructive + +Audit event_type → label/icon/tint (UI-SPEC Timeline Spec): + remediation_approved="{N} action(s) approved by {actor}" CheckCircle2 blue + remediation_completed="Remediation completed" ShieldCheck green + campaign_marked_false_positive="Marked as false positive" XCircle slate + campaign_classified → classification entries; any other → humanized fallback + Circle + muted + ++ + + ++ + +Task 1: ClassificationCard (REVIEW-04) +components/phishing/classification-card.tsx ++ - app/analyzer/reports/[id]/page.tsx (Card section + small presentational sub-component idiom) + - components/ui/status-badge.tsx (verdict + action chip badge shapes) + - components/ui/alert.tsx (the requires-approval warning Alert) + - lib/auth-client.ts (useSession export) and lib/permissions.ts (hasPermission signature — for the Reclassify button gate) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Classification Display Spec) + ++ Create `components/phishing/classification-card.tsx` (`'use client'`) exporting `ClassificationCard({ campaignId, classification, onReclassified })`. Wrap in ` +` with ` ` + leading lucide `Sparkles` (`h-4 w-4 mr-2 inline`) reading "Classification". Header row: verdict StatusBadge (per recipe above) + "{confidence}% confidence" inline + a right-aligned "Reclassify ticket" button (`variant="outline"` `size="sm"`) that is HIDDEN (not disabled) unless `hasPermission(role, 'phishing', 'analyze')` where role comes from `useSession()` (`(session?.user as {role?:string})?.role ?? 'user'`); on click it POSTs `/api/phishing/campaigns/${campaignId}/classify` then toasts and calls `onReclassified()`. Summary: `classification.summary` as `text-sm`. Reasons: ` ` one `
- ` per reasons entry. Recommended actions: an informational chip row of humanized labels (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule") as slate/muted StatusBadges — NO checkboxes or editable params here (that surface lives only in ActionAreaCard). If `requiresApproval === true`, render an inline amber-tone `
`: "This classification recommends a destructive action and requires explicit approval before remediation can proceed." This component renders nothing/omits itself when `classification` is null (the D-08 path handles that upstream). + + +grep -q "hasPermission" components/phishing/classification-card.tsx && grep -q "font-bold" components/phishing/classification-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "classification-card" | grep -qx 0 && echo OK ++ - `grep -q "requiresApproval" components/phishing/classification-card.tsx` succeeds and an Alert renders on that branch + - `grep -q "hasPermission(role, 'phishing', 'analyze')" components/phishing/classification-card.tsx` (or equivalent call) — Reclassify gated on analyze + - `grep -c "Checkbox" components/phishing/classification-card.tsx` returns 0 (no approve surface here) + - `npx tsc --noEmit --pretty` reports no error in this file + ++ + +Task 2: TimelineCard (REVIEW-02) +components/phishing/timeline-card.tsx ++ - components/ui/status-light.tsx (the 8px h-2 w-2 dot size token to reuse for the rail) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Timeline Spec — sources, event_type→icon/label/tint table, rail visual) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md ("No Analog Found" note for timeline-card — build fresh with div+Tailwind) + ++ Create `components/phishing/timeline-card.tsx` (`'use client'`) exporting `TimelineCard({ timeline }: { timeline: TimelineEntry[] })` (define the TimelineEntry union locally mirroring the detail route's shape, or import a shared type if plan 02 exports one). Wrap in ` +` with ` `. Render a vertical list (already ascending/oldest-first from the server merge — do NOT re-sort) as plain `div`s + Tailwind: a left rail with an 8px dot (`h-2 w-2 rounded-full`, tinted per entry) connected by a 1px `border-l`; right side shows the label (`text-sm`), a timestamp (`font-mono text-xs text-muted-foreground`, relative with the absolute value in a `title` attr), and actor when present. Map each entry.kind: 'report' → "Report linked — Ticket #{ticketNumber} ({companyName})" icon FileText muted; 'classification' → "Classified as {verdict} ({confidence}%)" icon Sparkles tinted per verdict recipe; 'audit' → per the event_type table (remediation_approved→CheckCircle2/blue, remediation_completed→ShieldCheck/green, campaign_marked_false_positive→XCircle/slate, any other→humanized snake_case→Title Case fallback with Circle/muted). No new reusable "Timeline" primitive — this is a one-off layout. + + +grep -q "h-2 w-2" components/phishing/timeline-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "timeline-card" | grep -qx 0 && echo OK ++ - `grep -q "remediation_completed" components/phishing/timeline-card.tsx` succeeds (audit event mapping present) + - `grep -q "border-l" components/phishing/timeline-card.tsx` succeeds (rail present) + - The component does not call `.sort(` on the timeline array (relies on server ordering) — `grep -c "\.sort(" components/phishing/timeline-card.tsx` returns 0 + - `npx tsc --noEmit --pretty` reports no error in this file + ++## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| classification/audit content → browser DOM | verdict/summary/reasons come from the classifier; audit actor/payload from audit_events — rendered as JSX text | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-22-10 | Tampering (XSS via classification reasons/summary or audit payload) | classification-card, timeline-card | mitigate | All fields rendered as JSX text (React auto-escapes); no `dangerouslySetInnerHTML` in either component | +| T-22-11 | Elevation of Privilege (Reclassify shown to unauthorized role) | classification-card | mitigate | Reclassify button hidden unless `hasPermission(role,'phishing','analyze')`; server route still enforces requirePermission independently | +| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) | + + ++- `npx tsc --noEmit --pretty` clean +- `npm test` still green + + ++ClassificationCard and TimelineCard render latest classification and full chronological timeline respectively, safely and per the UI-SPEC color/typography contract. + + + 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 new file mode 100644 index 0000000..6a127ed --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md @@ -0,0 +1,158 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 05 +type: execute +wave: 2 +depends_on: ["22-01", "22-03"] +files_modified: + - components/phishing/action-area-card.tsx +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" + artifacts: + - path: "components/phishing/action-area-card.tsx" + provides: "the only interactive remediation surface (REVIEW-05, D-03..06)" + exports: ["ActionAreaCard"] + key_links: + - from: "components/phishing/action-area-card.tsx" + to: "/api/phishing/campaigns/{id}/approve|remediate|mark-false-positive" + via: "fetch POST then onActionComplete refetch" + pattern: "campaigns/.+/(approve|remediate|mark-false-positive)" + - from: "components/phishing/action-area-card.tsx" + to: "lib/permissions.ts hasPermission" + via: "client-side gate mirroring the server route" + pattern: "hasPermission" +--- + ++Build `ActionAreaCard` — the single interactive remediation surface (REVIEW-05). It renders a checkbox list of the latest classification's recommended actions with inline-editable pre-filled params (D-03), submits exactly `ApproveActionInput[]` to the existing approve route, wires remediate + mark-false-positive (with AlertDialog confirmations), refetches after every action (D-04, no optimistic mutation), and gates all three buttons with the exact same `hasPermission()` check the server enforces (D-06/REVIEW-06), disabling-with-tooltip rather than hiding for approve/remediate. + +Purpose: This is the phase's only new write-triggering UI. Its permission gate must never diverge from the server, and its param submission must match `remediation-service.ts` exactly. +Output: 1 phishing component (2 tasks, same file). + + ++@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + ++@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md + + + ++From lib/services/remediation-default-params.ts (plan 01): + deriveDefaultParams(actionType, evidence: { requesterEmail; senderEmail; senderDomain; messageId }): Record ++ +From lib/services/remediation-service.ts (existing — the exact submit shape): + ApproveActionInput { actionType: string; params?: Record } + POST /approve body: { actions: ApproveActionInput[] } + +From lib/permissions.ts (existing, isomorphic — safe client-side): + hasPermission(roleName: string, resource: string, action: string): boolean + phishing actions: read | analyze | approve | remediate + +Existing write routes (reused verbatim — NOT modified): + POST /api/phishing/campaigns/{id}/approve -> 200 ApprovedRemediationAction[] + POST /api/phishing/campaigns/{id}/remediate (no body) -> 200 { campaignId, actions:[{id,actionType,status,alreadyCompleted}] } + POST /api/phishing/campaigns/{id}/mark-false-positive { reason? } -> 200 { campaignId, status:'false_positive', auditEventId }; 409 if approved/completed remediation exists + +Props (from the review page, plan 06): { campaignId, classification (latest, with recommendedActions), remediationActions (with status + completedAt + approvedBy), campaignStatus, campaignUpdatedAt, evidence (requesterEmail/senderEmail/senderDomain/messageId derived from campaign detail), onActionComplete } + +Analog to copy: components/rmm/rmm-dispatch-dialog.tsx (useSession + disabled-button + fetch-POST-toast). Tooltip primitive: components/ui/tooltip.tsx (plan 03). AlertDialog: components/ui/alert-dialog.tsx. + + + + ++ + +Task 1: Checkbox list + editable params + Approve selected (D-03) +components/phishing/action-area-card.tsx ++ - components/rmm/rmm-dispatch-dialog.tsx (useSession + fetch-POST-toast structure to copy) + - lib/services/remediation-default-params.ts (plan 01 — deriveDefaultParams) + - components/ui/checkbox.tsx (shadcn checkbox usage) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — checkbox list, 7-row param table, "Approve selected" button rules) + ++ Create `components/phishing/action-area-card.tsx` (`'use client'`) exporting `ActionAreaCard(props)`. Wrap in a shadcn ` +` with ` ` + leading lucide `ShieldAlert` (`h-4 w-4 mr-2 inline`). Render one row per string in `classification.recommendedActions`: a shadcn ` ` with a humanized label (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule"), plus an ALWAYS-VISIBLE params form beneath it (not gated on the checkbox) whose fields are pre-filled via `deriveDefaultParams(actionType, evidence)` and are editable per the UI-SPEC 7-row table: no_action renders "No parameters — informational verdict, no remediation needed." text instead of a form; warn_user→recipientEmail Input + message Textarea; block_sender→senderEmail + senderDomain Inputs; purge_message→messageId Input + mailboxes comma-separated Input (helper "Enter affected mailboxes, comma-separated"); reset_password→userPrincipalName Input; isolate_endpoint→deviceId Input (helper "No device identifier available from evidence — enter manually"); disable_forwarding_rule→userPrincipalName + ruleName Inputs. Hold per-row checked state and per-row edited params in `useState`. Add the "Approve selected" button (primary variant) that submits — enablement/tooltip gating comes in task 2, but wire the submit handler now: it POSTs `/api/phishing/campaigns/${campaignId}/approve` with body `{ actions: checkedRows.map(r => ({ actionType: r.actionType, params: r.currentParams })) }` (exactly ApproveActionInput[]; for purge_message parse the mailboxes comma-separated string into a string[] before submit), then on 2xx `toast.success` with the returned count and calls `props.onActionComplete()` (D-04 refetch — never mutate local state optimistically); on error `toast.error('Approve failed: {message}')`. Copy the fetch-POST-toast try/catch structure from rmm-dispatch-dialog.tsx. + + +grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx && grep -q "onActionComplete" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK ++ - `grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx` succeeds + - `grep -q "/approve" components/phishing/action-area-card.tsx` succeeds and the POST body uses `actions:` with `actionType`/`params` + - `grep -q "onActionComplete" components/phishing/action-area-card.tsx` succeeds (refetch, not optimistic) + - `npx tsc --noEmit --pretty` reports no error in this file + ++ + +Task 2: Remediate + mark-false-positive + permission/resolved gating (REVIEW-06, D-05, D-06) +components/phishing/action-area-card.tsx ++ - components/phishing/action-area-card.tsx (task 1 output — extend the same file) + - components/ui/alert-dialog.tsx (confirmation dialog usage) + - components/ui/tooltip.tsx (plan 03 — disabled-button explanations) + - lib/permissions.ts + lib/auth-client.ts (hasPermission + useSession) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — Buttons section, priority-ordered disable reasons, Copywriting Contract tooltips) + ++ Extend `action-area-card.tsx`. Derive role via `useSession()`: `const role = (session?.user as {role?:string})?.role ?? 'user'`; `const canApprove = hasPermission(role, 'phishing', 'approve')`; `const canRemediate = hasPermission(role, 'phishing', 'remediate')`. Define `resolved` = `campaignStatus === 'false_positive' || remediationActions.some(a => a.status === 'completed')`. Add two more buttons, all three ALWAYS rendered (never removed from DOM, D-05), each wrapped in a `Tooltip` when disabled. Evaluate each button's disabled reason in this priority order (first match wins, shown as the tooltip): + "Approve selected" (primary): (1) !canApprove → "Requires approve permission"; (2) resolved → resolved-state copy; (3) zero checked → "Select at least one action to approve"; else enabled. + "Remediate approved actions" (outline): (1) !canRemediate → "Requires remediate permission"; (2) resolved → resolved-state copy; (3) no remediationActions row with status==='approved' → "No approved actions to remediate"; else enabled — on click open an AlertDialog ("Remediate {N} approved action(s): {humanized types}. This executes the simulated remediation effect and cannot be undone." confirm label "Remediate", destructive variant) then POST `/api/phishing/campaigns/${campaignId}/remediate` (no body), toast + onActionComplete. + "Mark as false positive" (destructive): (1) !canApprove → "Requires approve permission"; (2) resolved → resolved-state copy; (3) any remediationActions row with status in ('approved','completed') → "Cannot mark false positive — this campaign already has approved or completed remediation" (mirrors the server 409 guard exactly); else enabled — on click open an AlertDialog ("Mark this campaign as a false positive? This cannot be undone — there is no way to reverse it later." confirm label "Mark as false positive", destructive) with an optional reason Textarea, then POST `/api/phishing/campaigns/${campaignId}/mark-false-positive` with `{ reason? }`, toast + onActionComplete. Resolved-state tooltip copy: remediated → "Already remediated on {completedAt date} by {approvedBy}" (derive from the completed remediationActions row); false positive → "Marked as false positive on {campaignUpdatedAt date}". All permission checks use `hasPermission()` from lib/permissions.ts (never a bespoke `role === 'admin'` check) so client and server can never drift (REVIEW-06). + ++ +grep -q "hasPermission(role, 'phishing', 'approve')" components/phishing/action-area-card.tsx && grep -q "hasPermission(role, 'phishing', 'remediate')" components/phishing/action-area-card.tsx && grep -q "mark-false-positive" components/phishing/action-area-card.tsx && ! grep -q "role === 'admin'" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK ++ - `grep -q "hasPermission(role, 'phishing', 'approve')" components/phishing/action-area-card.tsx` and `...'remediate')` both succeed + - `grep -c "role === 'admin'" components/phishing/action-area-card.tsx` returns 0 (no bespoke role check that could diverge from server) + - `grep -q "/remediate" components/phishing/action-area-card.tsx` and `grep -q "/mark-false-positive" components/phishing/action-area-card.tsx` succeed + - `grep -q "AlertDialog" components/phishing/action-area-card.tsx` succeeds (both destructive actions confirm) + - All three buttons render regardless of state (disabled, not removed) — verified by the absence of a conditional that omits a button when `resolved` (reviewer/manual check) + - `npx tsc --noEmit --pretty` reports no error in this file + ++## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser→API (write) | Operator triggers approve/remediate/mark-false-positive; the client gate is UX-only, the server route is the enforcement boundary | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-22-12 | Elevation of Privilege (unauthorized operator triggering a write) | action-area-card.tsx | mitigate | Client-side `hasPermission(role,'phishing','approve'|'remediate')` mirrors the server's `requirePermission` exactly (same function family); server remains the sole enforcement point — the client gate only prevents a failed request/UX confusion (REVIEW-06) | +| T-22-13 | Tampering (client/server permission drift) | action-area-card.tsx | mitigate | Uses `hasPermission()` from lib/permissions.ts — the identical statement the routes enforce — not a bespoke `role === 'admin'` string check (which could drift) | +| T-22-14 | Tampering (irreversible action mis-click) | remediate + mark-false-positive | mitigate | Both destructive transitions require an AlertDialog confirm before the POST; server-side idempotency (remediate) and the 409 guard (mark-false-positive) provide the real backstop | +| T-22-15 | Tampering (CSRF on write routes) | action-area-card.tsx | accept | Same-origin `fetch()` from a Better-Auth-session-cookie'd page, matching every existing write route's CSRF posture; no change introduced or required by this phase (22-RESEARCH Security Domain) | +| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) | + + ++- `npx tsc --noEmit --pretty` clean +- `npm test` still green +- Manual: as a `user` role, confirm approve/remediate/mark-false-positive are disabled with the correct tooltip; as admin, confirm approve→remediate→resolved flow disables buttons post-resolution + + ++The action card submits exact ApproveActionInput[], wires remediate + mark-false-positive with confirmations and refetch, and disables-with-tooltip using the same permission function the server enforces. + + + 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 new file mode 100644 index 0000000..ee4df7f --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md @@ -0,0 +1,159 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 06 +type: execute +wave: 3 +depends_on: ["22-02", "22-03", "22-04", "22-05"] +files_modified: + - app/phishing/tickets/[ticketId]/page.tsx + - app/phishing/page.tsx + - components/navigation/app-navigation.tsx +autonomous: true +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 renders classification, action area, evidence, and timeline for a grouped campaign; 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" + - "After any approve/remediate/mark-false-positive succeeds, the page refetches and re-renders from fresh server state" + - "Visiting /phishing lists recent campaigns and clicking a row navigates to /phishing/tickets/{firstReportTicketId}" + - "A 'Phishing' nav entry links to /phishing" + artifacts: + - path: "app/phishing/tickets/[ticketId]/page.tsx" + provides: "the ticket-scoped LiveLink review page (REVIEW-01..06)" + min_lines: 60 + - path: "app/phishing/page.tsx" + provides: "minimal campaigns list page (D-00)" + min_lines: 30 + - path: "components/navigation/app-navigation.tsx" + provides: "Phishing nav entry (D-02)" + contains: "/phishing" + key_links: + - from: "app/phishing/tickets/[ticketId]/page.tsx" + to: "/api/phishing/tickets/{ticketId}/campaign then /api/phishing/campaigns/{id}" + via: "two-step fetch in load()" + pattern: "tickets/.+/campaign" + - from: "app/phishing/page.tsx" + to: "/phishing/tickets/{firstReportTicketId}" + via: "router.push on row click" + pattern: "firstReportTicketId" +--- + ++Assemble the two pages and the nav entry. The ticket-scoped review page (`/phishing/tickets/{ticketId}`) is the LiveLink target: it resolves ticket→campaign, drives a five-state machine (loading / not-triaged / ungrouped / ready / error), composes the plan-03/04/05 cards, and refetches after every action (D-04). The minimal campaigns list page (`/phishing`, D-00) gives the nav somewhere to point and lets operators browse into the review page. A flat "Phishing" nav item (D-02) makes it discoverable. + +Purpose: This is the integration layer — every other plan's output is composed here into the operator-facing surfaces. +Output: 2 pages + 1 nav edit. + + ++@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + ++@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md + + + ++Routes consumed (plan 02): + GET /api/phishing/tickets/{ticketId}/campaign -> { found:boolean; reportId?; campaignId?:string|null; ticketNumber? } + GET /api/phishing/campaigns/{id} -> extended detail (campaign fields + reports + messages(headers/urls/attachments/bodyPreview) + indicators + classifications + remediationActions(+completedAt) + auditEvents + blastRadius + timeline) + GET /api/phishing/campaigns?limit&offset -> { items:[{ id, campaignKey, status, reportCount, firstSeenAt, lastSeenAt, firstReportTicketId }], total, limit, offset } + POST /api/phishing/tickets/{ticketId}/analyze (D-07 CTA) + +Components consumed: + components/phishing/{classification-card,action-area-card,evidence-card,timeline-card}.tsx (plans 03/04/05) + components/admin/DataTable.tsx (Column +[], data, totalCount, page, pageSize, onPageChange, onRowClick) + components/ui/{empty-state,alert,status-badge,skeleton-helpers}.tsx, components/navigation/page-header.tsx + +Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX8 entry (title/href/icon/description) is the exact shape; insert the new item immediately after PAX8, before "Backup Status". Add ShieldAlert to the lucide-react import block. No role gate (every role has phishing:read). + + + + ++ + +Task 1: Ticket-scoped review page (REVIEW-01, REVIEW-05, REVIEW-06) +app/phishing/tickets/[ticketId]/page.tsx ++ - app/analyzer/reports/[id]/page.tsx (use(params) + fetch + loading/error early-return + Card-stack layout idiom) + - components/phishing/classification-card.tsx, action-area-card.tsx, evidence-card.tsx, timeline-card.tsx (plans 03/04/05 — the cards composed here) + - components/ui/empty-state.tsx, components/ui/alert.tsx, components/ui/skeleton-helpers.tsx, components/navigation/page-header.tsx + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Ticket-scoped review page layout order, Empty & Error States, Copywriting Contract) + ++ Create `app/phishing/tickets/[ticketId]/page.tsx` as a `'use client'` page: `export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) { const { ticketId } = use(params); ... }`. Use the full chrome shell (D-01): ` +` inside ` ` (AppNavigation is supplied by the layout). Implement a state machine `'loading'|'not-triaged'|'ungrouped'|'ready'|'error'` with an async `load()`: fetch `/api/phishing/tickets/${ticketId}/campaign`; if `!found` → 'not-triaged'; if `campaignId == null` → 'ungrouped' (fetch that report's evidence for the standalone EvidenceCard); else fetch `/api/phishing/campaigns/${campaignId}` and → 'ready' (throw on non-2xx → 'error'). `useEffect(() => { void load(); }, [ticketId])`. Render per state: loading → `SkeletonHeader` + 3× `SkeletonCard`; not-triaged (D-07) → full-width `EmptyState` (icon SearchX, title "Not yet triaged", body + CTA "Analyze this ticket" per Copywriting Contract that POSTs `/api/phishing/tickets/${ticketId}/analyze` then re-runs load()); ungrouped (D-08) → info-tone ` ` with the D-08 copy followed by ` ` alone (no classification/action/timeline cards); error → destructive ` ` with the error copy + "Retry" button re-running load(); ready → in order: ` ` (full width), ` ` (full width, pass campaignId/classification/remediationActions/campaignStatus/campaignUpdatedAt and the derived evidence object {requesterEmail,senderEmail,senderDomain,messageId}), then ` `. Pass `load` as `onActionComplete`/`onReclassified` so every action refetches (D-04, no optimistic mutation). Derive the ActionAreaCard `evidence` fields from the campaign detail's primary message headers + primary report requesterEmail. Authentication is the existing Better Auth session only (the route is not in middleware publicRoutes) — do NOT add any token/query-param auth (REVIEW-01). ++ +grep -q "tickets/\${ticketId}/campaign" app/phishing/tickets/[ticketId]/page.tsx && grep -q "onActionComplete={load}" app/phishing/tickets/[ticketId]/page.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "tickets/\[ticketId\]/page" | grep -qx 0 && echo OK ++ - `grep -q "not-triaged" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "ungrouped" ...` both succeed (D-07/D-08 states present) + - `grep -q "/analyze" app/phishing/tickets/[ticketId]/page.tsx` succeeds (D-07 CTA) + - `grep -q "onActionComplete" app/phishing/tickets/[ticketId]/page.tsx` succeeds and points at the refetch function (D-04) + - `grep -c "searchParams\|token=" app/phishing/tickets/[ticketId]/page.tsx` returns 0 (no separate auth scheme — REVIEW-01) + - All four cards (ClassificationCard/ActionAreaCard/EvidenceCard/TimelineCard) are imported and rendered + - `npx tsc --noEmit --pretty` reports no error in this file + ++ + +Task 2: Campaigns list page (D-00) + Phishing nav entry (D-02) +app/phishing/page.tsx, components/navigation/app-navigation.tsx ++ - app/admin/data-browser/companies/page.tsx (DataTable + pagination-state + row-click-navigate idiom) + - components/admin/DataTable.tsx (confirmed props: columns/data/totalCount/page/pageSize/onPageChange/onRowClick) + - components/ui/empty-state.tsx, components/ui/status-badge.tsx, components/navigation/page-header.tsx + - components/navigation/app-navigation.tsx (the navigationItems array + the PAX8 entry shape + the lucide-react import block) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Campaigns list page layout + columns + Nav placement) + ++ Create `app/phishing/page.tsx` (`'use client'`) with ` +` inside ` `. Hold `campaigns`/`totalCount`/`page`/`pageSize(50)`/`isLoading` state; `fetchCampaigns(page)` calls `/api/phishing/campaigns?limit=50&offset={(page-1)*50}` and sets `items`/`total`. Render ` ` with columns per the UI-SPEC table: Campaign (`campaignKey` fallback "Campaign {id.slice(0,8)}", `font-mono text-sm`), Status (`StatusBadge` per the color table: open/proposed slate, false_positive slate, etc.), Reports (`reportCount`, right-aligned), First seen (`firstSeenAt`, relative + absolute title, `font-mono text-xs`), Last seen (`lastSeenAt`, same). `onRowClick` uses `useRouter().push('/phishing/tickets/' + campaign.firstReportTicketId)`. When `items.length === 0` render ` ` in place of the table. Then MODIFY `components/navigation/app-navigation.tsx`: add `ShieldAlert` to the lucide-react import block and insert a new `navigationItems` entry immediately after the PAX8 item and before "Backup Status": `{ title: 'Phishing', href: '/phishing', icon: ShieldAlert, description: 'Phishing/spam campaign triage, evidence, and remediation approval' }`. Do NOT add it to any super-admin/role-gated filter — it is visible to all roles (every role has phishing:read). + + +grep -q "firstReportTicketId" app/phishing/page.tsx && grep -q "title: 'Phishing'" components/navigation/app-navigation.tsx && grep -q "ShieldAlert" components/navigation/app-navigation.tsx && npx tsc --noEmit --pretty 2>&1 | grep -Ec "app/phishing/page|app-navigation" | grep -qx 0 && echo OK ++ - `grep -q "firstReportTicketId" app/phishing/page.tsx` succeeds (row-click navigates via ticket id, not a campaign-scoped page) + - `grep -q "/api/phishing/campaigns" app/phishing/page.tsx` succeeds (reuses the existing list endpoint) + - `grep -q "title: 'Phishing'" components/navigation/app-navigation.tsx` and `grep -q "href: '/phishing'" ...` succeed + - `grep -q "EmptyState" app/phishing/page.tsx` succeeds (empty state present) + - `npx tsc --noEmit --pretty` reports no error in either file + ++## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser (unauth) → page | Middleware redirects unauthenticated requests to /auth/sign-in; /phishing is not in publicRoutes | +| LiveLink-supplied ticket id → page | The numeric AT ticket id is untrusted URL input, validated server-side by the resolver route | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-22-16 | Spoofing / Elevation (unauthenticated LiveLink access) | both pages | mitigate | REVIEW-01 — existing Better Auth session only; /phishing absent from middleware publicRoutes so an unauth request redirects to sign-in; no token/query-param auth added | +| T-22-17 | Elevation of Privilege (action buttons for unauthorized role) | review page | mitigate | Action gating is delegated to ActionAreaCard's `hasPermission()` (plan 05); the page adds no relaxed check and the server routes enforce independently | +| T-22-18 | Information Disclosure (rendering unsanitized evidence) | review page | mitigate | Evidence rendering is delegated to EvidenceCard (plan 03 — inert URLs, plain-text body); the page passes data through, never via dangerouslySetInnerHTML | +| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) | + + ++- `npx tsc --noEmit --pretty` clean +- `npm test` full suite green +- Manual click-through (per 22-VALIDATION Manual-Only): review page in all four states (full-campaign, D-07 not-yet-triaged, D-08 ungrouped, load-error); approve→remediate→resolved flow; user-role sees disabled/hidden actions; blast-radius unavailable state with Mimecast unset; list page row-click navigates correctly + + ++Both operator surfaces work end-to-end: the LiveLink review page (all states, actions with refetch, session-only auth) and the discoverable campaigns list page with a working nav entry. + + + diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-VALIDATION.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-VALIDATION.md index bd1682c..4c9eb8c 100644 --- a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-VALIDATION.md +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-VALIDATION.md @@ -23,7 +23,7 @@ created: 2026-07-16 | **Full suite command** | `npm test` | | **Estimated runtime** | ~30 seconds | -**Confirmed constraint:** `vitest.config.ts`'s `test.include` is `['lib/**/*.test.ts']` only — it does NOT include `app/**` or `components/**`. React page/component code and Next.js API route handlers in this phase have **no automated test coverage under the current config**, consistent with CLAUDE.md's stated safety net (`npx tsc --noEmit --pretty`) for untested areas. Any genuinely new *pure logic* this phase introduces should be extracted into a `lib/services/*.ts` file specifically so it can be unit-tested; UI composition and route wiring fall back to type-check as the safety net. +**Confirmed constraint:** `vitest.config.ts`'s `test.include` is `['lib/**/*.test.ts']` only — it does NOT include `app/**` or `components/**`. React page/component code and Next.js API route handlers in this phase have **no automated test coverage under the current config**, consistent with CLAUDE.md's stated safety net (`npx tsc --noEmit --pretty`) for untested areas. Any genuinely new *pure logic* this phase introduces is extracted into a `lib/services/*.ts` file (plan 22-01) so it can be unit-tested; UI composition and route wiring fall back to type-check as the safety net. --- @@ -40,23 +40,27 @@ created: 2026-07-16 | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| -| TBD-resolver | TBD | TBD | REVIEW-01 | V5 Input Validation | `ticket_id` validated `Number.isFinite()` matching `analyze/route.ts` idiom; returns correct `{found, reportId, campaignId}` shape for no-report / ungrouped-report / grouped-campaign | unit | `npx vitest run lib/services/phishing-ticket-resolver.test.ts` | ❌ Wave 0 — extract resolver SQL into testable `lib/services/phishing-ticket-resolver.ts` | ⬜ pending | -| TBD-defaults | TBD | TBD | REVIEW-04 | — | Default-param derivation table (7 action types → client-side default params) matches `ApproveActionInput` shape exactly | unit | `npx vitest run lib/services/remediation-default-params.test.ts` | ❌ Wave 0 — extract UI-SPEC's Action Area default-param logic into a pure function | ⬜ pending | -| TBD-timeline | TBD | TBD | REVIEW-02 | — | Timeline merge/sort (reports + classifications + audit_events, chronological) — only if server-merged | unit | `npx vitest run lib/services/phishing-timeline.test.ts` | ❌ Wave 0 — only if planner chooses server-merge approach | ⬜ pending | -| TBD-page | TBD | TBD | REVIEW-03, REVIEW-05, REVIEW-06 | V4 Access Control, Tampering (XSS) | Page rendering, action button gating/disabling per `hasPermission()`, evidence display (no `dangerouslySetInnerHTML`, URLs inert-text-only per D-09) | manual | — | N/A — no `app/**`/`components/**` test infra | ⬜ pending | +| 22-01-T1 (resolver) | 22-01 | 1 | REVIEW-01 | T-22-01/02 (V5 Input Validation) | `resolveTicketToCampaign` returns correct `{found, reportId, campaignId, ticketNumber}` for no-report / ungrouped-report / grouped-campaign; parameterized query only | unit | `npx vitest run lib/services/phishing-ticket-resolver.test.ts` | ❌ Wave 0 — created in 22-01 T1 | ⬜ pending | +| 22-01-T2 (defaults) | 22-01 | 1 | REVIEW-04 | — | Default-param derivation (7 action types + unknown fallback) matches `ApproveActionInput` params shape exactly | unit | `npx vitest run lib/services/remediation-default-params.test.ts` | ❌ Wave 0 — created in 22-01 T2 | ⬜ pending | +| 22-01-T3 (timeline) | 22-01 | 1 | REVIEW-02 | — | `mergeTimeline` merges reports + classifications + audit_events into one ascending chronological array (server-merge approach chosen) | unit | `npx vitest run lib/services/phishing-timeline.test.ts` | ❌ Wave 0 — created in 22-01 T3 | ⬜ pending | +| 22-02-T1/T2/T3 (routes) | 22-02 | 2 | REVIEW-01, REVIEW-02, REVIEW-03, REVIEW-04 | T-22-03..06 (V4 Access Control, V5, Info Disclosure) | Resolver + extended detail + extended list routes; requirePermission gates; fresh blastRadius scoped to real sender/recipient (not empty-string); no `completed_at` column reference | type-check (no route test infra) | `npx tsc --noEmit --pretty` | N/A — no `app/**` test infra | ⬜ pending | +| 22-03-T1/T2 (evidence) | 22-03 | 1 | REVIEW-03 | T-22-07/08/09 (Tampering/XSS) | URLs inert copy-only (no ``, D-09); body preview plain ``, no `dangerouslySetInnerHTML`; attachment metadata only; blast-radius unavailable state explicit | manual + grep gates | `grep`-based DOM-safety assertions in plan acceptance_criteria | N/A — no `components/**` test infra | ⬜ pending | +| 22-04-T1/T2 (class/timeline UI) | 22-04 | 1 | REVIEW-02, REVIEW-04 | T-22-10/11 | ClassificationCard verdict/reasons/actions + approval warning; Reclassify gated on analyze; TimelineCard renders 3 merged sources | manual | — | N/A — no `components/**` test infra | ⬜ pending | +| 22-05-T1/T2 (action card) | 22-05 | 2 | REVIEW-05, REVIEW-06 | T-22-12..15 (Elevation, Tampering, CSRF) | Approve submits exact `ApproveActionInput[]`; remediate/mark-false-positive confirmed + refetch (D-04); all buttons gated by server-identical `hasPermission()`, disabled-with-tooltip (no bespoke `role === 'admin'`) | manual (+ grep gate on `hasPermission`) | `grep`-based permission-gate assertions in plan acceptance_criteria | N/A — no `components/**` test infra | ⬜ pending | +| 22-06-T1/T2 (pages+nav) | 22-06 | 3 | REVIEW-01, REVIEW-05, REVIEW-06 | T-22-16/17/18 (Spoofing/Elevation/Info Disclosure) | Ticket→campaign two-step fetch, 5-state machine, refetch after action, Better-Auth-session-only (no token/query-param auth); list-page row-click → `/phishing/tickets/{firstReportTicketId}`; nav entry | manual | — | N/A — no `app/**` test infra | ⬜ pending | *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* -*Task IDs are placeholders (TBD-*) pending the planner's actual plan/task numbering — the planner must populate this table's Task ID / Plan / Wave columns to match the real PLAN.md files it produces.* +**Server-merge decision:** The planner chose the server-merge approach for the timeline (RESEARCH.md Alternatives Considered), so `lib/services/phishing-timeline.ts` + test IS created (22-01 T3) — the conditional Wave 0 item is now a firm requirement. --- ## Wave 0 Requirements -- [ ] `lib/services/phishing-ticket-resolver.ts` + `lib/services/phishing-ticket-resolver.test.ts` — ticket→campaign resolution as a testable pure function (REVIEW-01), not inline route logic -- [ ] `lib/services/remediation-default-params.ts` + `.test.ts` — 7-action-type default-param derivation table extracted from UI-SPEC's Action Area spec (REVIEW-04), not inline component logic -- [ ] (Conditional) `lib/services/phishing-timeline.ts` + `.test.ts` — only if the planner chooses to server-merge the timeline (REVIEW-02) rather than merge client-side -- [ ] No framework install needed — vitest already configured and passing for the rest of the codebase +- [x] `lib/services/phishing-ticket-resolver.ts` + `.test.ts` — ticket→campaign resolution as a testable pure function (REVIEW-01) → **22-01 Task 1** +- [x] `lib/services/remediation-default-params.ts` + `.test.ts` — 7-action-type default-param derivation extracted from UI-SPEC's Action Area spec (REVIEW-04) → **22-01 Task 2** +- [x] `lib/services/phishing-timeline.ts` + `.test.ts` — server-merge timeline (REVIEW-02) chosen → **22-01 Task 3** +- [x] No framework install needed — vitest already configured and passing for the rest of the codebase --- @@ -74,11 +78,11 @@ created: 2026-07-16 ## Validation Sign-Off -- [ ] All tasks have automated verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING references -- [ ] No watch-mode flags -- [ ] Feedback latency < 30s +- [x] All tasks have automated verify or Wave 0 dependencies +- [x] Sampling continuity: no 3 consecutive tasks without automated verify (Wave 0 unit tests + per-task type-check + grep DOM/permission gates) +- [x] Wave 0 covers all MISSING references +- [x] No watch-mode flags +- [x] Feedback latency < 30s - [x] `nyquist_compliant: true` set in frontmatter **Approval:** pending