From 87008a5da6205432d0b0182689478908628097b2 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 14:27:11 -0400 Subject: [PATCH 1/5] feat(22-03): add tooltip primitive + inert UrlList component (D-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npx shadcn add tooltip generates components/ui/tooltip.tsx (official registry, no npm dependency added) - components/phishing/url-list.tsx renders extracted URLs as inert text with copy-to-clipboard only — no /href, no , no navigating onClick per D-09 --- components/phishing/url-list.tsx | 50 ++++++++++++++++++++++++++++ components/ui/tooltip.tsx | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 components/phishing/url-list.tsx create mode 100644 components/ui/tooltip.tsx diff --git a/components/phishing/url-list.tsx b/components/phishing/url-list.tsx new file mode 100644 index 0000000..f7f4002 --- /dev/null +++ b/components/phishing/url-list.tsx @@ -0,0 +1,50 @@ +'use client'; + +/* UrlList — inert, copy-only rendering of URLs extracted from a reported + * phishing/spam email (REVIEW-03, D-09). + * + * D-09 is a deliberately STRICTER-than-sanitization posture: extracted URLs + * are attacker-controlled content and must never be rendered as anything + * clickable. There is no anchor tag with a navigation attribute, no + * ``, and no navigating `onClick` anywhere in this file — the only + * affordance is copy-to-clipboard via an icon-only button. Do not "improve" + * this by adding a real link. */ + +import { Copy } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; + +interface UrlListProps { + urls: string[]; +} + +export function UrlList({ urls }: UrlListProps) { + if (urls.length === 0) { + return ( +

No URLs found in this message.

+ ); + } + + async function handleCopy(url: string) { + await navigator.clipboard.writeText(url); + toast.success('Copied'); + } + + return ( +
    + {urls.map((url, index) => ( +
  • + {url} + +
  • + ))} +
+ ); +} diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..ec65c1e --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import { Tooltip as TooltipPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } From 4ec5ba4979b6b2785eba27126f88065ef1399c87 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 14:29:34 -0400 Subject: [PATCH 2/5] feat(22-03): add EvidenceCard tabbed EML evidence display (REVIEW-03) - components/phishing/evidence-card.tsx renders Headers, URLs, Attachments, Body preview, and Blast Radius tabs for a selected message - Body preview renders inside a
  as plain JSX text only, never via a raw-HTML injection prop
- URLs tab delegates to UrlList (D-09 inert copy-only)
- Blast radius renders explicit unavailable-state copy or a
  matched/delivered/held/rejected/clicked stat row + per-recipient
  table when ok
- CardTitle explicitly overridden with font-bold per UI-SPEC typography
---
 components/phishing/evidence-card.tsx | 353 ++++++++++++++++++++++++++
 1 file changed, 353 insertions(+)
 create mode 100644 components/phishing/evidence-card.tsx

diff --git a/components/phishing/evidence-card.tsx b/components/phishing/evidence-card.tsx
new file mode 100644
index 0000000..0c87341
--- /dev/null
+++ b/components/phishing/evidence-card.tsx
@@ -0,0 +1,353 @@
+'use client';
+
+/* EvidenceCard — tabbed, read-only rendering of a reported message's parsed
+ * EML evidence: Headers, URLs, Attachments, Body preview, Blast Radius
+ * (REVIEW-03).
+ *
+ * This is the phase's biggest XSS-exposure surface: every value rendered
+ * here originates from an attacker-controlled email. Body preview is
+ * rendered ONLY as JSX text inside a 
 (React auto-escapes) — never via
+ * a raw-HTML injection prop. Extracted URLs are delegated to , which
+ * enforces the D-09 inert-copy-only contract. Attachment content is never
+ * fetched or rendered — filename/content-type/size/hash metadata only
+ * (EVID-04). */
+
+import { useMemo, useState } from 'react';
+import { formatDistanceToNow } from 'date-fns';
+import { FileText, Copy } from 'lucide-react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { StatusBadge } from '@/components/ui/status-badge';
+import { Button } from '@/components/ui/button';
+import { UrlList } from '@/components/phishing/url-list';
+import { toast } from 'sonner';
+
+// ── Shapes (from the extended campaign detail route) ──────────────────────
+
+export interface EvidenceMessageHeaders {
+  from: { displayName: string | null; email: string | null; domain: string | null };
+  replyTo: string | null;
+  returnPath: string | null;
+  to: string[];
+  cc: string[];
+  subject: string | null;
+  date: string | null;
+  messageId: string | null;
+  receivedChain: string[];
+  authResults: { spf?: string | null; dkim?: string | null; dmarc?: string | null };
+  authResultsOriginal?: Record | null;
+}
+
+export interface EvidenceAttachment {
+  filename: string | null;
+  contentType: string | null;
+  size: number;
+  checksum: string | null;
+  related: boolean;
+}
+
+export interface EvidenceMessage {
+  id: string;
+  ticketNumber: string | null;
+  reportCreatedAt: string | null;
+  headers: EvidenceMessageHeaders;
+  urls: string[];
+  attachments: EvidenceAttachment[];
+  bodyPreview: string;
+}
+
+export type BlastRadiusResult =
+  | { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string }
+  | {
+      status: 'ok';
+      matched: number;
+      delivered: number;
+      held: number;
+      rejected: number;
+      clicked: number;
+      perRecipient: Array<{ recipient: string; status: 'delivered' | 'held' | 'rejected' | 'unknown' }>;
+    };
+
+interface EvidenceCardProps {
+  messages: EvidenceMessage[];
+  blastRadius: BlastRadiusResult;
+}
+
+// ── Small local helpers ─────────────────────────────────────────────────
+
+function formatBytes(bytes: number | null): string {
+  if (!bytes) return '0 B';
+  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+  let i = 0;
+  let val = bytes;
+  while (val >= 1024 && i < units.length - 1) {
+    val /= 1024;
+    i++;
+  }
+  return `${val.toFixed(1)} ${units[i]}`;
+}
+
+function authBadge(value: string | null | undefined) {
+  const normalized = (value ?? '').toLowerCase();
+  if (normalized === 'pass') {
+    return pass;
+  }
+  if (normalized === 'fail') {
+    return fail;
+  }
+  return {normalized || 'none'};
+}
+
+function recipientStatusBadge(status: string) {
+  switch (status) {
+    case 'delivered':
+      return delivered;
+    case 'held':
+      return held;
+    case 'rejected':
+      return rejected;
+    default:
+      return unknown;
+  }
+}
+
+async function copyToClipboard(value: string) {
+  await navigator.clipboard.writeText(value);
+  toast.success('Copied');
+}
+
+function unavailableCopy(result: { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string }) {
+  if (result.reason === 'not_configured') {
+    return "Blast radius unavailable — Mimecast isn't configured for this environment.";
+  }
+  return `Blast radius lookup failed: ${result.error ?? 'Unknown error'}. Classification proceeded without it.`;
+}
+
+// ── Component ───────────────────────────────────────────────────────────
+
+export function EvidenceCard({ messages, blastRadius }: EvidenceCardProps) {
+  const [selectedId, setSelectedId] = useState(messages[0]?.id);
+
+  const message = useMemo(
+    () => messages.find((m) => m.id === selectedId) ?? messages[0] ?? null,
+    [messages, selectedId],
+  );
+
+  return (
+    
+      
+        
+          
+          Evidence
+        
+      
+      
+        {messages.length > 1 && (
+          
+ +
+ )} + + {!message ? ( +

No message evidence available.

+ ) : ( + + + Headers + URLs + Attachments + Body preview + Blast Radius + + + +
+ From + {message.headers.from.email ?? '—'} + + Display name + {message.headers.from.displayName ?? '—'} + + Sender domain + {message.headers.from.domain ?? '—'} + + Reply-To + {message.headers.replyTo ?? '—'} + + Return-Path + {message.headers.returnPath ?? '—'} + + To + {message.headers.to.join(', ') || '—'} + + Cc + {message.headers.cc.join(', ') || '—'} + + Subject + {message.headers.subject ?? '—'} + + Date + {message.headers.date ?? '—'} + + Message-ID + {message.headers.messageId ?? '—'} + + SPF + {authBadge(message.headers.authResults.spf)} + + DKIM + {authBadge(message.headers.authResults.dkim)} + + DMARC + {authBadge(message.headers.authResults.dmarc)} +
+ + {message.headers.receivedChain.length > 0 && ( + + + Received chain + +
    + {message.headers.receivedChain.map((hop, index) => ( +
  • + {hop} +
  • + ))} +
+
+
+
+ )} +
+ + + + + + + {message.attachments.length === 0 ? ( +

No attachments on this message.

+ ) : ( + + + + Filename + Content-Type + Size + Hash + + + + {message.attachments.map((attachment, index) => ( + + {attachment.filename ?? '—'} + + + {attachment.contentType ?? 'unknown'} + + + {formatBytes(attachment.size)} + + {attachment.checksum ? ( +
+ + {attachment.checksum} + + +
+ ) : ( + + )} +
+
+ ))} +
+
+ )} +
+ + +
+                {message.bodyPreview}
+              
+
+ + + {blastRadius.status === 'unavailable' ? ( +

{unavailableCopy(blastRadius)}

+ ) : ( +
+
+
+
Matched
+
{blastRadius.matched}
+
+
+
Delivered
+
{blastRadius.delivered}
+
+
+
Held
+
{blastRadius.held}
+
+
+
Rejected
+
{blastRadius.rejected}
+
+
+
Clicked
+
{blastRadius.clicked}
+
+
+ + + + Recipient + Status + + + + {blastRadius.perRecipient.map((recipient, index) => ( + + {recipient.recipient} + {recipientStatusBadge(recipient.status)} + + ))} + +
+
+ )} +
+
+ )} +
+
+ ); +} From 2d5761bf6f959ee8cccf4f8fb503b58e82bd9ace Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 14:29:40 -0400 Subject: [PATCH 3/5] docs(22-03): log pre-existing itglue-search test failures as deferred Out-of-scope failures observed during npm test for plan 22-03; unrelated to this plan's files. --- .../deferred-items.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md new file mode 100644 index 0000000..570ae5f --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md @@ -0,0 +1,9 @@ + +## 22-03: Pre-existing test failures (out of scope) + +- `lib/services/analyzer/itglue-search.test.ts` — 2 failing assertions + (`tolerates per-call failures` test) observed during `npm test` run for + plan 22-03. Unrelated to `components/ui/tooltip.tsx`, + `components/phishing/url-list.tsx`, or `components/phishing/evidence-card.tsx`. + Not fixed per deviation-rules scope boundary (pre-existing failure in an + unrelated file). Flagging for follow-up. From f7516c04c783c234a42721df678399873d767331 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 14:30:59 -0400 Subject: [PATCH 4/5] docs(22-03): complete evidence display plan --- .../22-03-SUMMARY.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-SUMMARY.md diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-SUMMARY.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-SUMMARY.md new file mode 100644 index 0000000..3522eb9 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-SUMMARY.md @@ -0,0 +1,106 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 03 +subsystem: ui +tags: [shadcn, radix, tooltip, react, tabs, phishing, xss-safety, clipboard] + +# Dependency graph +requires: [] +provides: + - "components/ui/tooltip.tsx — shadcn tooltip primitive (Tooltip/TooltipTrigger/TooltipContent/TooltipProvider)" + - "components/phishing/url-list.tsx — inert copy-only UrlList component (D-09)" + - "components/phishing/evidence-card.tsx — tabbed EvidenceCard (Headers/URLs/Attachments/Body preview/Blast Radius)" +affects: [22-04, 22-05, 22-06] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "shadcn official-registry primitive addition via `npx shadcn add ` (no npm dependency delta — radix-ui already a project dependency)" + - "Inert-render pattern for attacker-controlled URLs: text + copy-to-clipboard button only, never
/href/Link (D-09)" + - "Body-preview rendering exclusively inside a JSX-text
, never a raw-HTML injection prop, for attacker-controlled email content"
+
+key-files:
+  created:
+    - components/ui/tooltip.tsx
+    - components/phishing/url-list.tsx
+    - components/phishing/evidence-card.tsx
+  modified: []
+
+key-decisions:
+  - "EvidenceCard exports EvidenceMessage/EvidenceAttachment/BlastRadiusResult TypeScript interfaces alongside the component so plan 06 (review page composition) and the extended detail route can share the exact shape without re-declaring it."
+  - "Message selector (multi-report Select) keeps local useState for selectedId, defaulting to messages[0] (most-recently-linked, per the caller's expected sort order) rather than fetching/sorting inside the component — EvidenceCard stays a pure presentational component per the plan's stated purpose."
+  - "Used date-fns formatDistanceToNow for the Select's relative-date label, matching the existing precedent in components/admin/SyncDashboard.tsx and components/mobile/EngagementProfileHeader.tsx rather than introducing a new relTime() helper."
+
+patterns-established:
+  - "Pattern: inert-evidence-display — any future surface rendering attacker-controlled extracted data (URLs, hashes, raw text) should copy UrlList's stricter-than-sanitization approach: read-only /
 + copy button, zero interactive/navigating affordances."
+
+requirements-completed: [REVIEW-03]
+
+# Metrics
+duration: 20min
+completed: 2026-07-16
+---
+
+# Phase 22 Plan 03: Evidence Display (Tooltip primitive + UrlList + EvidenceCard) Summary
+
+**Tabbed, read-only EML evidence display (Headers/URLs/Attachments/Body preview/Blast Radius) built on a new shadcn tooltip primitive and a D-09-compliant inert URL list, with zero clickable-link surface and zero raw-HTML rendering of attacker-controlled email content.**
+
+## Performance
+
+- **Duration:** ~20 min
+- **Started:** 2026-07-16T18:10:33Z
+- **Completed:** 2026-07-16T18:29:40Z
+- **Tasks:** 2 completed
+- **Files modified:** 3 created
+
+## Accomplishments
+- Added the `tooltip` shadcn primitive via the official registry (`npx shadcn add tooltip`) — zero new npm dependency, since `radix-ui` was already a project dependency.
+- Built `UrlList` (`components/phishing/url-list.tsx`): extracted URLs render as inert `` monospace text with an icon-only copy-to-clipboard button; no ``/`href`, no ``, no navigating `onClick` anywhere in the file (D-09).
+- Built `EvidenceCard` (`components/phishing/evidence-card.tsx`): tabbed display of Headers (2-col definition list + SPF/DKIM/DMARC badges + collapsible received chain), URLs (delegates to `UrlList`), Attachments (metadata-only table with copy-hash affordance), Body preview (plain-text `
`, never raw-HTML rendering), and Blast Radius (explicit unavailable-state copy or stat row + per-recipient table).
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Add tooltip primitive + build UrlList (D-09 inert)** - `87008a5` (feat)
+2. **Task 2: EvidenceCard — tabbed EML evidence (REVIEW-03)** - `4ec5ba4` (feat)
+
+Additional commit: `2d5761b` (docs) — logged pre-existing, out-of-scope test failures to `deferred-items.md`.
+
+**Plan metadata:** SUMMARY commit (this file) follows below.
+
+## Files Created/Modified
+- `components/ui/tooltip.tsx` - shadcn tooltip primitive (Tooltip/TooltipTrigger/TooltipContent/TooltipProvider), generated by the official registry, no new npm dependency
+- `components/phishing/url-list.tsx` - inert, copy-only rendering of extracted URLs (D-09); exports `UrlList({ urls })`
+- `components/phishing/evidence-card.tsx` - tabbed EML evidence display; exports `EvidenceCard({ messages, blastRadius })` plus the `EvidenceMessage`/`EvidenceAttachment`/`BlastRadiusResult` shared TypeScript shapes
+
+## Decisions Made
+- Exported the evidence data shapes (`EvidenceMessage`, `EvidenceAttachment`, `BlastRadiusResult`) directly from `evidence-card.tsx` rather than a separate types file, since this plan is the sole owner of the shape today and plan 02's extended detail route / plan 06's review page can import from here without duplication.
+- Kept the multi-report `