diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-SUMMARY.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-SUMMARY.md new file mode 100644 index 0000000..d26981b --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-SUMMARY.md @@ -0,0 +1,109 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 05 +subsystem: ui +tags: [react, nextjs, shadcn, tooltip, alert-dialog, permissions, remediation] + +# Dependency graph +requires: + - phase: 22 (plan 01) + provides: deriveDefaultParams() (lib/services/remediation-default-params.ts) and the existing ApproveActionInput contract in lib/services/remediation-service.ts + - phase: 22 (plan 03) + provides: components/ui/tooltip.tsx (new shadcn primitive) and the UrlList inert-render precedent + - phase: 20 + provides: POST /api/phishing/campaigns/{id}/approve|remediate|mark-false-positive routes (reused verbatim, not modified) +provides: + - ActionAreaCard — the only interactive remediation surface for the Phase 22 review page (plan 06 consumes it) +affects: [22-06 (ticket-scoped review page composition)] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "GatedButton: buttons always rendered (D-05), wrapped in a Tooltip explaining the disabled reason via a focusable trigger (native disabled buttons don't reliably fire hover events)" + - "Client-side permission gate mirrors the server exactly via hasPermission() from lib/permissions.ts — never a bespoke role string check (REVIEW-06)" + - "Controlled AlertDialog (open/onOpenChange, no AlertDialogTrigger) to avoid nested asChild ref-forwarding issues with GatedButton's conditional Tooltip wrapper" + +key-files: + created: [components/phishing/action-area-card.tsx] + modified: [] + +key-decisions: + - "AlertDialog is driven by controlled open state, not AlertDialogTrigger — GatedButton's onClick sets the open flag directly, avoiding nested Radix asChild composition (Tooltip wrapper -> AlertDialogTrigger -> Button) which is fragile for ref/prop forwarding" + - "purge_message's mailboxes field is edited as a comma-separated string in the form and converted to string[] only at submit time (normalizeParamsForSubmit), keeping the Input controlled with a simple string value" + - "resolvedTooltipCopy() checks for a completed remediation_actions row before checking campaigns.status === 'false_positive', since the two guards make both true simultaneously impossible in practice but a completed action is the more specific data point" + +patterns-established: + - "GatedButton (local to this file): always-mounted button + priority-ordered disabled reason + Tooltip-on-disabled — reusable shape for any future phishing action button" + +requirements-completed: [REVIEW-05, REVIEW-06] + +# Metrics +duration: 25min +completed: 2026-07-16 +--- + +# Phase 22 Plan 05: Action Area Card Summary + +**ActionAreaCard: checkbox-driven Approve/Remediate/Mark-false-positive surface gated by the exact same `hasPermission()` the server enforces, with AlertDialog confirmations and refetch-only state updates.** + +## Performance + +- **Duration:** 25 min +- **Started:** 2026-07-16T18:18:00Z (approx.) +- **Completed:** 2026-07-16T18:42:54Z +- **Tasks:** 2 +- **Files modified:** 1 + +## Accomplishments +- Built `ActionAreaCard` (`components/phishing/action-area-card.tsx`) — one checkbox row per recommended action, with an always-visible params form pre-filled via `deriveDefaultParams(actionType, evidence)` and editable per the UI-SPEC's 7-row table +- "Approve selected" submits exactly `ApproveActionInput[]` to the existing `POST /approve` route +- "Remediate approved actions" and "Mark as false positive" wired to the existing `POST /remediate` and `POST /mark-false-positive` routes, each behind an `AlertDialog` confirmation +- All three buttons stay mounted in the DOM at all times (D-05) and are disabled-with-tooltip (never hidden) using priority-ordered reasons that mirror the server's own guards exactly +- Every action refetches via `onActionComplete()` on success — no optimistic local mutation (D-04) +- Defense-in-depth null-guard on `classification` renders an informational note instead of ever dereferencing `recommendedActions` + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Checkbox list + editable params + Approve selected (D-03)** - `11190ab` (feat) +2. **Task 2: Remediate + mark-false-positive + permission/resolved gating (REVIEW-06, D-05, D-06)** - `1a7a592` (feat) + +**Plan metadata:** committed as part of this SUMMARY commit (worktree mode — orchestrator handles final metadata commit after merge) + +## Files Created/Modified +- `components/phishing/action-area-card.tsx` - The only interactive remediation surface: checkbox list with editable pre-filled params, Approve/Remediate/Mark-false-positive buttons gated by `hasPermission()`, AlertDialog confirmations for the two destructive actions, refetch-on-success throughout + +## Decisions Made +- AlertDialogs are controlled (`open`/`onOpenChange`) rather than driven by `AlertDialogTrigger`, since `GatedButton` conditionally wraps its `Button` in a `Tooltip`+`span` when disabled — nesting an `AlertDialogTrigger asChild` around that composite would create a fragile double-`asChild` ref-forwarding chain. A plain `onClick={() => setDialogOpen(true)}` on the (non-disabled) button is simpler and equally correct, since the button's own `disabled` attribute already prevents the click when a blocking reason applies. +- `purge_message`'s `mailboxes` param is stored as a comma-separated string while being edited (to keep the `Input` a simple controlled string field) and converted to `string[]` only at submit time via `normalizeParamsForSubmit()` — matches the UI-SPEC's editable-field description ("Input, comma-separated") while still submitting the exact array shape `ApproveActionInput` expects. +- `resolvedTooltipCopy()` checks for a completed `remediation_actions` row first, falling back to the `campaigns.status === 'false_positive'` copy — the two states can't coexist per the server's D-04 guard (`markCampaignFalsePositive` rejects when approved/completed remediation exists), so this is a defensive ordering rather than a live ambiguity. + +## Deviations from Plan + +None — plan executed exactly as written. Both tasks matched their specified `` and `` blocks; no Rule 1-4 auto-fixes were needed. + +## Issues Encountered + +TypeScript flagged `classification is possibly 'null'` inside `handleApprove` even though it's defined after the module-level null-guard — TS does not retain narrowing of an outer-scope `const` across a nested closure defined later in the same function body (a known TS limitation, not a bug in the guard itself). Resolved by binding `const activeClassification = classification;` immediately after the guard and referencing that binding inside `handleApprove`, rather than asserting `classification!` at each use site. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +`ActionAreaCard` is ready to be composed into the ticket-scoped review page (plan 06), which supplies `campaignId`, the latest `classification` (or `null`), `remediationActions`, `campaignStatus`, `campaignUpdatedAt`, and the derived `evidence` object from the extended campaign detail endpoint. No blockers — `npx tsc --noEmit --pretty` and `npx eslint components/phishing/action-area-card.tsx` are both clean; `npm test` shows only the pre-existing, already-documented `itglue-search.test.ts` failures (2 failed | 411 passed of 413), unrelated to this plan's file. + +--- +*Phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve* +*Completed: 2026-07-16* + +## Self-Check: PASSED + +- FOUND: components/phishing/action-area-card.tsx +- FOUND: .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-SUMMARY.md +- FOUND commit: 11190ab (Task 1) +- FOUND commit: 1a7a592 (Task 2) +- FOUND commit: e78f1b0 (SUMMARY) diff --git a/components/phishing/action-area-card.tsx b/components/phishing/action-area-card.tsx new file mode 100644 index 0000000..40fe6b5 --- /dev/null +++ b/components/phishing/action-area-card.tsx @@ -0,0 +1,616 @@ +'use client'; + +/** + * ActionAreaCard — the only interactive remediation surface on the phishing + * campaign review page (REVIEW-05, REVIEW-06). Renders one checkbox row per + * recommended action with an always-visible, editable params form + * pre-filled via deriveDefaultParams(), and submits exactly + * ApproveActionInput[] to the existing /approve route. + * + * D-04: every action refetches via onActionComplete() on success — this + * component never optimistically mutates local campaign state. + */ + +import { useEffect, useState, type ReactNode } from 'react'; +import { Loader2, ShieldAlert } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { toast } from 'sonner'; +import { useSession } from '@/lib/auth-client'; +import { hasPermission } from '@/lib/permissions'; +import { deriveDefaultParams } from '@/lib/services/remediation-default-params'; + +export interface RemediationActionSummary { + id: string; + actionType: string; + status: string; + completedAt: string | null; + approvedBy: string | null; +} + +export interface ActionAreaClassification { + recommendedActions: string[]; +} + +export interface ActionAreaEvidence { + requesterEmail: string | null; + senderEmail: string | null; + senderDomain: string | null; + messageId: string | null; +} + +interface ActionAreaCardProps { + campaignId: string; + classification: ActionAreaClassification | null; + remediationActions: RemediationActionSummary[]; + campaignStatus: string; + campaignUpdatedAt: string; + evidence: ActionAreaEvidence; + onActionComplete: () => void; +} + +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(' ') + ); +} + +interface ActionRowState { + checked: boolean; + params: Record; +} + +/** + * Normalizes a row's editable params back into the submit shape. The only + * field that needs conversion is purge_message's `mailboxes` — edited as a + * comma-separated string in the form, submitted as `string[]` to match + * ApproveActionInput exactly. + */ +function normalizeParamsForSubmit(actionType: string, params: Record): Record { + if (actionType === 'purge_message' && typeof params.mailboxes === 'string') { + return { + ...params, + mailboxes: params.mailboxes + .split(',') + .map((m) => m.trim()) + .filter(Boolean), + }; + } + return params; +} + +function ActionParamsForm({ + actionType, + params, + onChange, +}: { + actionType: string; + params: Record; + onChange: (params: Record) => void; +}) { + function setField(key: string, value: string) { + onChange({ ...params, [key]: value }); + } + + const str = (key: string) => (typeof params[key] === 'string' ? (params[key] as string) : ''); + + switch (actionType) { + case 'no_action': + return ( +

+ No parameters — informational verdict, no remediation needed. +

+ ); + case 'warn_user': + return ( +
+
+ + setField('recipientEmail', e.target.value)} + /> +
+
+ +