chore: merge executor worktree (worktree-agent-a972575d901be6c4f)

# Conflicts:
#	.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md
This commit is contained in:
lorentz 2026-07-16 14:32:29 -04:00
commit 4f976b741f
5 changed files with 425 additions and 14 deletions

View file

@ -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 |

View file

@ -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', <action>) 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 <interfaces> 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 `<ul>`, 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 `<interfaces>` 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 `<interfaces>` 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

View file

@ -2,15 +2,14 @@
Pre-existing failures/warnings discovered during execution but out of scope
for the current plan (not touched by this plan's files, not caused by this
plan's changes).
plan's changes). Logged, not fixed, per the executor's scope-boundary rule.
## Plan 01
## Pre-existing `itglue-search.test.ts` failures
- `lib/services/analyzer/itglue-search.test.ts` — 2 pre-existing failing
tests (`itglueSearch > tolerates per-call failures ...`, doc count
mismatches). Unrelated to phishing-ticket-resolver /
remediation-default-params / phishing-timeline (this plan's files). Not
modified by any commit in this plan. Confirmed present before this plan's
changes (file untouched in `git diff`). Full-suite run: `2 failed | 411
passed (413)` with only these 2 pre-existing failures — all other tests,
including the 3 new files added by this plan, are green.
- **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`
- **Discovered during:** Plan 22-01 (`phishing-ticket-resolver` / `remediation-default-params` / `phishing-timeline`) and independently confirmed during Plan 22-04 (`ClassificationCard` + `TimelineCard`).
- **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.

View file

@ -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<ClassificationCardData['verdict'], string> = {
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<string, string> = {
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 (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<CardTitle className="font-bold">
<Sparkles className="h-4 w-4 mr-2 inline" />
Classification
</CardTitle>
{canReclassify && (
<Button
variant="outline"
size="sm"
disabled={isReclassifying}
onClick={handleReclassify}
>
{isReclassifying ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : null}
Reclassify ticket
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<StatusBadge variantClass={VERDICT_VARIANT_CLASS[classification.verdict]}>
{classification.verdict}
</StatusBadge>
{classification.confidence != null && (
<span className="text-sm text-muted-foreground">
{classification.confidence}% confidence
</span>
)}
</div>
{classification.summary && (
<p className="text-sm">{classification.summary}</p>
)}
{classification.reasons.length > 0 && (
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
{classification.reasons.map((reason, idx) => (
<li key={idx}>{reason}</li>
))}
</ul>
)}
{classification.recommendedActions.length > 0 && (
<div className="flex flex-wrap gap-2">
{classification.recommendedActions.map((actionType) => (
<StatusBadge
key={actionType}
variantClass="bg-slate-500/15 text-slate-600"
>
{humanizeAction(actionType)}
</StatusBadge>
))}
</div>
)}
{classification.requiresApproval && (
<Alert className="border-amber-500/50 text-amber-600 dark:border-amber-500 [&>svg]:text-amber-600">
<AlertDescription>
This classification recommends a destructive action and requires
explicit approval before remediation can proceed.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}

View file

@ -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<string, unknown>;
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 (
<Card>
<CardHeader>
<CardTitle className="font-bold">Timeline</CardTitle>
</CardHeader>
<CardContent>
{timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">No timeline events yet.</p>
) : (
<div className="space-y-0">
{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 (
<div key={idx} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn('h-2 w-2 rounded-full shrink-0 mt-1.5', dotClass)}
/>
{!isLast && <span className="border-l flex-1 w-0 min-h-4" />}
</div>
<div className="pb-4 min-w-0">
<div className="flex items-center gap-1.5 text-sm">
<Icon className={cn('h-3.5 w-3.5 shrink-0', textClass)} />
<span>{label}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-xs text-muted-foreground" title={absolute}>
{relative}
</span>
{actor && (
<span className="text-xs text-muted-foreground">{actor}</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}