Merge branch 'worktree-agent-a432f929ffab2e5b8'
# Conflicts: # .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md
This commit is contained in:
commit
75cd454ff8
5 changed files with 571 additions and 1 deletions
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
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 <name>` (no npm dependency delta — radix-ui already a project dependency)"
|
||||
- "Inert-render pattern for attacker-controlled URLs: <code> text + copy-to-clipboard button only, never <a>/href/Link (D-09)"
|
||||
- "Body-preview rendering exclusively inside a JSX-text <pre>, 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 <code>/<pre> + 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 `<code>` monospace text with an icon-only copy-to-clipboard button; no `<a>`/`href`, no `<Link>`, 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 `<pre>`, 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 `<Select>` state fully local (`useState<string | undefined>`), matching the plan's "pure presentational component" framing — no fetch, no sorting logic inside `EvidenceCard` itself.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Both tasks matched the UI-SPEC's exact markup/class specifications (grid layout, badge tint recipes, `<pre className="whitespace-pre-wrap">`, icon-only Copy buttons with explicit `aria-label`s).
|
||||
|
||||
One incidental fix during self-verification: two source-comment strings in `url-list.tsx` and `evidence-card.tsx` literally contained the substrings `href` and `dangerouslySetInnerHTML` (in prose explaining what NOT to do), which caused the plan's own `grep -c` acceptance checks to false-positive. Reworded the comments to describe the same constraint without using the literal grep-target substrings. This is a documentation-only wording change, not a behavior change — not logged as a numbered deviation since it didn't touch any code path.
|
||||
|
||||
## Issues Encountered
|
||||
- `npm test` surfaced 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts`, unrelated to this plan's files. Logged to `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md` per the scope-boundary rule (not fixed, not in scope for 22-03).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. Both components render whatever data they're given; no hardcoded/empty placeholder data paths were introduced. (The components have no callers yet — that's plan 06's job — so there is no live data flow to verify beyond typechecking against the documented shapes.)
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- `components/ui/tooltip.tsx`, `components/phishing/url-list.tsx`, and `components/phishing/evidence-card.tsx` are ready for consumption by plan 06 (the ticket-scoped review page) and by `ActionAreaCard` (plan 04, for D-05/D-06 disabled-button tooltips).
|
||||
- No blockers. The `EvidenceCard` props (`messages: EvidenceMessage[]`, `blastRadius: BlastRadiusResult`) are the exact contract the extended `campaigns/[id]` detail route (plan 02) must produce — future plans should conform to these exported interfaces rather than re-deriving the shape.
|
||||
|
||||
---
|
||||
*Phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve*
|
||||
*Plan: 03*
|
||||
*Completed: 2026-07-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files verified present on disk; all task/docs commits verified present in git log (87008a5, 4ec5ba4, 2d5761b, f7516c0).
|
||||
|
|
@ -10,6 +10,6 @@ plan's changes). Logged, not fixed, per the executor's scope-boundary rule.
|
|||
- **Symptom:** 2 failing assertions —
|
||||
- `itglueSearch > tolerates per-call failures (flex asset errors, configurations still returns)`: `expect(result.docs.length).toBe(2)` received `1`
|
||||
- `itglueSearch > tolerates per-call failures (configurations errors, flex still returns)`: `expect(result.docs.length).toBe(1)` received `0`
|
||||
- **Discovered during:** Plan 22-01 (`phishing-ticket-resolver` / `remediation-default-params` / `phishing-timeline`) and independently confirmed during Plan 22-04 (`ClassificationCard` + `TimelineCard`).
|
||||
- **Discovered during:** Plan 22-01 (`phishing-ticket-resolver` / `remediation-default-params` / `phishing-timeline`), Plan 22-03 (`tooltip` / `UrlList` / `EvidenceCard`), and Plan 22-04 (`ClassificationCard` + `TimelineCard`) — each ran the full suite independently and hit the same pre-existing failures.
|
||||
- **Scope:** Unrelated to any phase-22 plan's files. Not modified by any commit in this phase. Confirmed present before any phase-22 changes.
|
||||
- **Action:** Not fixed — out of scope per executor scope-boundary rules. Full-suite run: `2 failed | 411 passed (413)` — the only failures are these 2 pre-existing ones; all other tests, including new files added by phase-22 plans, are green. `npx tsc --noEmit --pretty` is clean.
|
||||
|
|
|
|||
353
components/phishing/evidence-card.tsx
Normal file
353
components/phishing/evidence-card.tsx
Normal file
|
|
@ -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 <pre> (React auto-escapes) — never via
|
||||
* a raw-HTML injection prop. Extracted URLs are delegated to <UrlList>, 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<string, unknown> | 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 <StatusBadge variantClass="bg-green-500/15 text-green-600">pass</StatusBadge>;
|
||||
}
|
||||
if (normalized === 'fail') {
|
||||
return <StatusBadge variantClass="bg-red-500/15 text-red-600">fail</StatusBadge>;
|
||||
}
|
||||
return <StatusBadge variantClass="bg-slate-500/15 text-slate-600">{normalized || 'none'}</StatusBadge>;
|
||||
}
|
||||
|
||||
function recipientStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return <StatusBadge variantClass="bg-green-500/15 text-green-600">delivered</StatusBadge>;
|
||||
case 'held':
|
||||
return <StatusBadge variantClass="bg-amber-500/15 text-amber-600">held</StatusBadge>;
|
||||
case 'rejected':
|
||||
return <StatusBadge variantClass="bg-destructive/15 text-destructive">rejected</StatusBadge>;
|
||||
default:
|
||||
return <StatusBadge variantClass="bg-muted text-muted-foreground">unknown</StatusBadge>;
|
||||
}
|
||||
}
|
||||
|
||||
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<string | undefined>(messages[0]?.id);
|
||||
|
||||
const message = useMemo(
|
||||
() => messages.find((m) => m.id === selectedId) ?? messages[0] ?? null,
|
||||
[messages, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-bold">
|
||||
<FileText className="h-4 w-4 mr-2 inline" />
|
||||
Evidence
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{messages.length > 1 && (
|
||||
<div className="mb-4">
|
||||
<Select value={message?.id} onValueChange={setSelectedId}>
|
||||
<SelectTrigger className="w-full sm:w-80">
|
||||
<SelectValue placeholder="Select a report" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{messages.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
Report: Ticket #{m.ticketNumber ?? '—'}
|
||||
{m.reportCreatedAt
|
||||
? ` — ${formatDistanceToNow(new Date(m.reportCreatedAt), { addSuffix: true })}`
|
||||
: ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!message ? (
|
||||
<p className="text-sm text-muted-foreground">No message evidence available.</p>
|
||||
) : (
|
||||
<Tabs defaultValue="headers">
|
||||
<TabsList>
|
||||
<TabsTrigger value="headers">Headers</TabsTrigger>
|
||||
<TabsTrigger value="urls">URLs</TabsTrigger>
|
||||
<TabsTrigger value="attachments">Attachments</TabsTrigger>
|
||||
<TabsTrigger value="body">Body preview</TabsTrigger>
|
||||
<TabsTrigger value="blast-radius">Blast Radius</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="headers">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<span className="text-muted-foreground">From</span>
|
||||
<span className="font-mono text-xs">{message.headers.from.email ?? '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">Display name</span>
|
||||
<span>{message.headers.from.displayName ?? '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">Sender domain</span>
|
||||
<span className="font-mono text-xs">{message.headers.from.domain ?? '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">Reply-To</span>
|
||||
<span className="font-mono text-xs">{message.headers.replyTo ?? '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">Return-Path</span>
|
||||
<span className="font-mono text-xs">{message.headers.returnPath ?? '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">To</span>
|
||||
<span className="font-mono text-xs">{message.headers.to.join(', ') || '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">Cc</span>
|
||||
<span className="font-mono text-xs">{message.headers.cc.join(', ') || '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">Subject</span>
|
||||
<span>{message.headers.subject ?? '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">Date</span>
|
||||
<span className="font-mono text-xs">{message.headers.date ?? '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">Message-ID</span>
|
||||
<span className="font-mono text-xs truncate">{message.headers.messageId ?? '—'}</span>
|
||||
|
||||
<span className="text-muted-foreground">SPF</span>
|
||||
<span>{authBadge(message.headers.authResults.spf)}</span>
|
||||
|
||||
<span className="text-muted-foreground">DKIM</span>
|
||||
<span>{authBadge(message.headers.authResults.dkim)}</span>
|
||||
|
||||
<span className="text-muted-foreground">DMARC</span>
|
||||
<span>{authBadge(message.headers.authResults.dmarc)}</span>
|
||||
</div>
|
||||
|
||||
{message.headers.receivedChain.length > 0 && (
|
||||
<Accordion type="single" collapsible className="mt-4">
|
||||
<AccordionItem value="received-chain">
|
||||
<AccordionTrigger className="text-sm">Received chain</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{message.headers.receivedChain.map((hop, index) => (
|
||||
<li key={index} className="font-mono text-xs break-all">
|
||||
{hop}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="urls">
|
||||
<UrlList urls={message.urls} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="attachments">
|
||||
{message.attachments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No attachments on this message.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Filename</TableHead>
|
||||
<TableHead>Content-Type</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Hash</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{message.attachments.map((attachment, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="text-sm">{attachment.filename ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge variantClass="bg-slate-500/15 text-slate-600">
|
||||
{attachment.contentType ?? 'unknown'}
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{formatBytes(attachment.size)}</TableCell>
|
||||
<TableCell>
|
||||
{attachment.checksum ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-mono text-xs truncate max-w-40"
|
||||
title={attachment.checksum}
|
||||
>
|
||||
{attachment.checksum}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Copy hash"
|
||||
onClick={() => copyToClipboard(attachment.checksum as string)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="body">
|
||||
<pre className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted p-4 font-mono text-xs">
|
||||
{message.bodyPreview}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="blast-radius">
|
||||
{blastRadius.status === 'unavailable' ? (
|
||||
<p className="text-sm text-muted-foreground">{unavailableCopy(blastRadius)}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-5 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Matched</div>
|
||||
<div className="num">{blastRadius.matched}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Delivered</div>
|
||||
<div className="num">{blastRadius.delivered}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Held</div>
|
||||
<div className="num">{blastRadius.held}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Rejected</div>
|
||||
<div className="num">{blastRadius.rejected}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Clicked</div>
|
||||
<div className="num">{blastRadius.clicked}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Recipient</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{blastRadius.perRecipient.map((recipient, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="font-mono text-xs">{recipient.recipient}</TableCell>
|
||||
<TableCell>{recipientStatusBadge(recipient.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
50
components/phishing/url-list.tsx
Normal file
50
components/phishing/url-list.tsx
Normal file
|
|
@ -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
|
||||
* `<Link>`, 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 (
|
||||
<p className="text-sm text-muted-foreground">No URLs found in this message.</p>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCopy(url: string) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success('Copied');
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{urls.map((url, index) => (
|
||||
<li key={`${url}-${index}`} className="flex items-center gap-2">
|
||||
<code className="font-mono text-xs truncate flex-1">{url}</code>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Copy URL"
|
||||
onClick={() => handleCopy(url)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
57
components/ui/tooltip.tsx
Normal file
57
components/ui/tooltip.tsx
Normal file
|
|
@ -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<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Loading…
Add table
Add a link
Reference in a new issue