From 14adddfdf0d0a0f5cf88d402ba12f183c6a86443 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 14:27:29 -0400 Subject: [PATCH 1/3] feat(22-04): add ClassificationCard read-only verdict display - Renders latest classification verdict/confidence/summary/reasons - Recommended-action chips (informational, no checkboxes) - Requires-approval warning Alert when requiresApproval is true - Reclassify button gated on hasPermission(role, 'phishing', 'analyze') - Returns null when classification is missing (empty-state handled by plan 06) --- components/phishing/classification-card.tsx | 161 ++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 components/phishing/classification-card.tsx diff --git a/components/phishing/classification-card.tsx b/components/phishing/classification-card.tsx new file mode 100644 index 0000000..017468b --- /dev/null +++ b/components/phishing/classification-card.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useState } from 'react'; +import { Sparkles, Loader2 } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { toast } from 'sonner'; +import { useSession } from '@/lib/auth-client'; +import { hasPermission } from '@/lib/permissions'; + +export interface ClassificationCardData { + id: string; + verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; + confidence: string | null; + summary: string | null; + reasons: string[]; + recommendedActions: string[]; + requiresApproval: boolean; + createdAt: string; +} + +interface ClassificationCardProps { + campaignId: string; + classification: ClassificationCardData | null; + onReclassified: () => void; +} + +const VERDICT_VARIANT_CLASS: Record = { + SPAM: 'bg-slate-500/15 text-slate-600', + UNWANTED: 'bg-amber-500/15 text-amber-600', + THREAT: 'bg-destructive/15 text-destructive', +}; + +const ACTION_LABEL: Record = { + 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', +}; + +function humanizeAction(actionType: string): string { + return ( + ACTION_LABEL[actionType] ?? + actionType + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + ); +} + +export function ClassificationCard({ + campaignId, + classification, + onReclassified, +}: ClassificationCardProps) { + const { data: session } = useSession(); + const [isReclassifying, setIsReclassifying] = useState(false); + const role = (session?.user as { role?: string } | undefined)?.role ?? 'user'; + const canReclassify = hasPermission(role, 'phishing', 'analyze'); + + // Guard: this card renders nothing for a null classification. The review + // page (not this card) chooses the replacement UI for both null-classification + // paths — the default "grouped but not yet classified" empty state (plan 06) + // and the D-08 ungrouped-report Alert (plan 06) — so we never dereference + // classification.verdict/.reasons/etc. here. + if (classification == null) return null; + + async function handleReclassify() { + setIsReclassifying(true); + try { + const res = await fetch(`/api/phishing/campaigns/${campaignId}/classify`, { + method: 'POST', + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? data.error ?? 'Reclassify failed'); + toast.success('Ticket reclassified'); + onReclassified(); + } catch (err) { + toast.error(`Reclassify failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + } finally { + setIsReclassifying(false); + } + } + + return ( + + +
+ + + Classification + + {canReclassify && ( + + )} +
+
+ +
+ + {classification.verdict} + + {classification.confidence != null && ( + + {classification.confidence}% confidence + + )} +
+ + {classification.summary && ( +

{classification.summary}

+ )} + + {classification.reasons.length > 0 && ( +
    + {classification.reasons.map((reason, idx) => ( +
  • {reason}
  • + ))} +
+ )} + + {classification.recommendedActions.length > 0 && ( +
+ {classification.recommendedActions.map((actionType) => ( + + {humanizeAction(actionType)} + + ))} +
+ )} + + {classification.requiresApproval && ( + + + This classification recommends a destructive action and requires + explicit approval before remediation can proceed. + + + )} +
+
+ ); +} From e990a320b2d20405fc505ee63864e1f60a701563 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 14:29:15 -0400 Subject: [PATCH 2/3] feat(22-04): add TimelineCard chronological event renderer - Merges reports/classifications/audit-events into one ascending list (relies on server ordering, no client-side sort) - Per-kind icon/label/tint: FileText for reports, Sparkles tinted by verdict for classifications, event_type table for audit rows (remediation_approved/completed, campaign_marked_false_positive, campaign_classified, humanized fallback for anything else) - 8px rail dot + border-l connector per UI-SPEC Timeline Spec --- components/phishing/timeline-card.tsx | 153 ++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 components/phishing/timeline-card.tsx diff --git a/components/phishing/timeline-card.tsx b/components/phishing/timeline-card.tsx new file mode 100644 index 0000000..f1bcf81 --- /dev/null +++ b/components/phishing/timeline-card.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { FileText, Sparkles, CheckCircle2, ShieldCheck, XCircle, Circle } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +export type TimelineEntry = + | { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null } + | { kind: 'classification'; at: string; verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; confidence: string | null } + | { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown }; + +interface TimelineCardProps { + timeline: TimelineEntry[]; +} + +const VERDICT_TINT: Record<'SPAM' | 'UNWANTED' | 'THREAT', string> = { + SPAM: 'bg-slate-500 text-slate-600', + UNWANTED: 'bg-amber-500 text-amber-600', + THREAT: 'bg-destructive text-destructive', +}; + +function humanizeEventType(eventType: string): string { + return eventType + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +function renderEntry(entry: TimelineEntry): { + label: string; + icon: React.ComponentType<{ className?: string }>; + dotClass: string; + textClass: string; +} { + if (entry.kind === 'report') { + const ticketLabel = entry.ticketNumber ? `Ticket #${entry.ticketNumber}` : 'Ticket'; + const company = entry.companyName ? ` (${entry.companyName})` : ''; + return { + label: `Report linked — ${ticketLabel}${company}`, + icon: FileText, + dotClass: 'bg-muted-foreground', + textClass: 'text-muted-foreground', + }; + } + + if (entry.kind === 'classification') { + const confidence = entry.confidence != null ? ` (${entry.confidence}%)` : ''; + return { + label: `Classified as ${entry.verdict}${confidence}`, + icon: Sparkles, + dotClass: VERDICT_TINT[entry.verdict].split(' ')[0], + textClass: VERDICT_TINT[entry.verdict].split(' ')[1], + }; + } + + // entry.kind === 'audit' + const payload = (entry.payload ?? {}) as Record; + switch (entry.eventType) { + case 'remediation_approved': { + const count = + (Array.isArray(payload.actionIds) && payload.actionIds.length) || + (Array.isArray(payload.actions) && payload.actions.length) || + 0; + return { + label: `${count} action(s) approved by ${entry.actor ?? 'unknown'}`, + icon: CheckCircle2, + dotClass: 'bg-blue-500', + textClass: 'text-blue-600', + }; + } + case 'remediation_completed': + return { + label: 'Remediation completed', + icon: ShieldCheck, + dotClass: 'bg-green-500', + textClass: 'text-green-600', + }; + case 'campaign_marked_false_positive': + return { + label: 'Marked as false positive', + icon: XCircle, + dotClass: 'bg-slate-500', + textClass: 'text-slate-600', + }; + case 'campaign_classified': { + const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | undefined; + const tint = verdict ? VERDICT_TINT[verdict] : 'bg-muted-foreground text-muted-foreground'; + return { + label: verdict ? `Classified as ${verdict}` : 'Campaign classified', + icon: Sparkles, + dotClass: tint.split(' ')[0], + textClass: tint.split(' ')[1], + }; + } + default: + return { + label: humanizeEventType(entry.eventType), + icon: Circle, + dotClass: 'bg-muted-foreground', + textClass: 'text-muted-foreground', + }; + } +} + +export function TimelineCard({ timeline }: TimelineCardProps) { + return ( + + + Timeline + + + {timeline.length === 0 ? ( +

No timeline events yet.

+ ) : ( +
+ {timeline.map((entry, idx) => { + const { label, icon: Icon, dotClass, textClass } = renderEntry(entry); + const isLast = idx === timeline.length - 1; + const actor = entry.kind === 'audit' ? entry.actor : null; + const absolute = new Date(entry.at).toLocaleString(); + const relative = formatDistanceToNow(new Date(entry.at), { addSuffix: true }); + return ( +
+
+ + {!isLast && } +
+
+
+ + {label} +
+
+ + {relative} + + {actor && ( + {actor} + )} +
+
+
+ ); + })} +
+ )} +
+
+ ); +} From fdc6a52dde3e837e8bebbeed0def7da15e9061ec Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 14:30:52 -0400 Subject: [PATCH 3/3] docs(22-04): complete ClassificationCard + TimelineCard plan - Add 22-04-SUMMARY.md documenting the two components delivered - Log pre-existing unrelated itglue-search.test.ts failures to deferred-items.md (out of scope for this plan) - Mark REVIEW-02, REVIEW-04 complete in REQUIREMENTS.md --- .planning/REQUIREMENTS.md | 8 +- .../22-04-SUMMARY.md | 98 +++++++++++++++++++ .../deferred-items.md | 15 +++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-SUMMARY.md create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index dcce7d9..df497ff 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -117,14 +117,14 @@ destructive remediation gated behind explicit human approval. (LiveLink supplies the ticket ID as dynamic content, not the internal campaign UUID), authenticated via the existing Better Auth session only — no separate token or query-param auth scheme -- [ ] **REVIEW-02**: The page displays the campaign's timeline — linked +- [x] **REVIEW-02**: The page displays the campaign's timeline — linked reports, classification history, and audit events (classify/approve/ remediate/mark-false-positive) — in chronological order - [ ] **REVIEW-03**: The page displays the gathered evidence — parsed EML headers/URLs/attachments, sanitized body preview, and Mimecast blast-radius data (including an explicit `unavailable` state when Mimecast isn't configured) — never rendering a raw/unsanitized body or unredacted secrets -- [ ] **REVIEW-04**: The page displays the current classification (SPAM/ +- [x] **REVIEW-04**: The page displays the current classification (SPAM/ UNWANTED/THREAT), confidence, reasons, and recommended remediation action(s) - [ ] **REVIEW-05**: An operator can approve, remediate, or mark a campaign as @@ -204,9 +204,9 @@ Populated during roadmap creation. | NOTE-01 | Phase 21 | Complete | | ACCESS-01 | Phase 18 | Complete | | REVIEW-01 | Phase 22 | Pending | -| REVIEW-02 | Phase 22 | Pending | +| REVIEW-02 | Phase 22 | Complete | | REVIEW-03 | Phase 22 | Pending | -| REVIEW-04 | Phase 22 | Pending | +| REVIEW-04 | Phase 22 | Complete | | REVIEW-05 | Phase 22 | Pending | | REVIEW-06 | Phase 22 | Pending | diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-SUMMARY.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-SUMMARY.md new file mode 100644 index 0000000..8afbbf7 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-SUMMARY.md @@ -0,0 +1,98 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 04 +subsystem: ui +tags: [react, nextjs, shadcn, phishing, tailwind, lucide] + +# Dependency graph +requires: + - phase: 22-01/02/03 + provides: campaign detail data shape (classification + timeline arrays) these components render +provides: + - "ClassificationCard — read-only latest-verdict display with Reclassify action (REVIEW-04)" + - "TimelineCard — chronological merged-event renderer (REVIEW-02)" +affects: [22-05, 22-06] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "CardTitle className=\"font-bold\" override for the 700-weight card heading (UI-SPEC Typography contract)" + - "Client-side hasPermission(role, 'phishing', ) gate via useSession(), hiding (not disabling) the affordance" + - "Null-classification early-return guard — page-level empty states (plan 06) own the replacement UI, not the card" + +key-files: + created: + - components/phishing/classification-card.tsx + - components/phishing/timeline-card.tsx + modified: [] + +key-decisions: + - "TimelineCard defines its own local TimelineEntry union (per plan's fallback instruction) rather than importing from plan 02, since plan 04 has no depends_on and plan 02's extended detail route may not exist in this worktree yet." + - "Audit event_type 'campaign_classified' is rendered with the same Sparkles/verdict-tinted treatment as 'classification' kind entries (per the plan's note: \"campaign_classified → classification entries\"), distinct from the generic humanized fallback used for any other/future event_type." + - "remediation_approved action count derived defensively from payload.actionIds.length, falling back to payload.actions.length, then 0 — matches the two audit-payload shapes actually written by lib/services/remediation-service.ts." + +patterns-established: + - "Timeline rail: 8px h-2 w-2 rounded-full dot + 1px border-l connector, built with plain divs/Tailwind (UI-SPEC explicitly calls this a one-off, not a new primitive)." + +requirements-completed: [REVIEW-02, REVIEW-04] + +# Metrics +duration: 12min +completed: 2026-07-16 +--- + +# Phase 22 Plan 04: ClassificationCard + TimelineCard Summary + +**Two read-only presentational components — ClassificationCard (verdict/confidence/reasons/action-chips + approval warning, Reclassify gated on `phishing:analyze`) and TimelineCard (chronological reports+classifications+audit-events merge) — built per the UI-SPEC color/typography contract with zero new dependencies.** + +## Performance + +- **Duration:** ~12 min +- **Started:** 2026-07-16T18:18:00Z +- **Completed:** 2026-07-16T18:29:48Z +- **Tasks:** 2/2 completed +- **Files modified:** 2 created + +## Accomplishments +- `ClassificationCard` renders the latest classification's verdict badge, confidence, summary, reasons list, and informational recommended-action chips; shows an amber `Alert` when `requiresApproval` is true; hides (not disables) the Reclassify button unless the session role has `phishing:analyze`; returns `null` when `classification` is missing so the review page's empty states (plan 06) own that surface. +- `TimelineCard` merges `report` / `classification` / `audit` timeline entries into a single ascending vertical list (relies on server ordering, never calls `.sort()`), with a per-kind icon/label/tint mapping including the full `audit_events.event_type` table from the UI-SPEC (`remediation_approved`, `remediation_completed`, `campaign_marked_false_positive`, `campaign_classified`, and a humanized fallback for anything else). + +## Task Commits + +1. **Task 1: ClassificationCard (REVIEW-04)** - `14adddf` (feat) +2. **Task 2: TimelineCard (REVIEW-02)** - `e990a32` (feat) + +**Plan metadata:** commit pending (this SUMMARY + REQUIREMENTS) + +## Files Created/Modified +- `components/phishing/classification-card.tsx` - `ClassificationCard({ campaignId, classification, onReclassified })` — verdict `StatusBadge`, confidence, summary, reasons `
    `, recommended-action chips, requires-approval `Alert`, permission-gated Reclassify button that POSTs `/api/phishing/campaigns/{id}/classify`. +- `components/phishing/timeline-card.tsx` - `TimelineCard({ timeline })` — locally-defined `TimelineEntry` union, per-kind rendering, 8px rail-dot + `border-l` layout, `date-fns` `formatDistanceToNow` for relative timestamps with the absolute value in a `title` attribute. + +## Decisions Made +- Defined `TimelineEntry` locally in `timeline-card.tsx` rather than importing a shared type, since this plan has `depends_on: []` and plan 02's extended detail route (which shapes this array) is not guaranteed to exist yet in a parallel-wave worktree. The union mirrors the plan's `` block exactly (`kind: 'report' | 'classification' | 'audit'`). +- Treated audit `event_type: 'campaign_classified'` as its own case (Sparkles icon, verdict-tinted per `payload.verdict`) rather than falling through to the generic "any other" fallback, per the plan's explicit `` note ("campaign_classified → classification entries"). + +## Deviations from Plan + +None — plan executed exactly as written. One out-of-scope, pre-existing test failure was discovered while running the plan's `npm test` verification step and logged (not fixed) per the executor's scope-boundary rule — see `## Deferred Issues` below. + +## Deferred Issues + +- `lib/services/analyzer/itglue-search.test.ts` has 2 failing assertions (`docs.length` mismatches in "tolerates per-call failures" tests). This file is unrelated to either component built in this plan and was not modified by any phase-22 plan. Logged to `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md`; not fixed (out of scope). All other 394 tests pass. + +## Verification Results + +- `npx tsc --noEmit --pretty` — clean, no errors anywhere in the repo (including both new files). +- `npm test` — 394/396 passing; the 2 pre-existing failures are unrelated to this plan (see Deferred Issues above). +- Task-level acceptance criteria (grep checks for `hasPermission`, `font-bold`, `requiresApproval`, zero `Checkbox` occurrences, `h-2 w-2`, `remediation_completed`, `border-l`, zero `.sort(` occurrences) all passed as specified in the plan. + +## Known Stubs + +None — both components are fully wired to the props/interfaces specified in the plan; no hardcoded empty values or placeholder text. + +## Threat Flags + +None — both components render all classification/audit content as JSX text (React auto-escaping, no `dangerouslySetInnerHTML`), matching threat register items T-22-10 (XSS mitigation) and T-22-11 (Reclassify button gated client-side on `hasPermission`, with the server route independently enforcing the same permission per the plan's threat model). + +## Self-Check: PASSED 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..a73d4c8 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md @@ -0,0 +1,15 @@ +# Deferred Items — Phase 22 + +Items discovered during execution that are out of scope for the current plan +(pre-existing, unrelated to the files this plan touches). Logged, not fixed, +per the executor's scope-boundary rule. + +## 22-04: Pre-existing `itglue-search.test.ts` failures + +- **Discovered during:** Plan 22-04 (`ClassificationCard` + `TimelineCard`), running `npm test` for the plan's verification step. +- **File:** `lib/services/analyzer/itglue-search.test.ts` +- **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` +- **Scope:** Unrelated to this plan's files (`components/phishing/classification-card.tsx`, `components/phishing/timeline-card.tsx`). Not touched by plan 22-04 or any prior phase-22 plan. +- **Action:** Not fixed — out of scope per executor scope-boundary rules. All other 394 tests pass; `npx tsc --noEmit --pretty` is clean.