---
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 <span> 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 `<action>` and `<acceptance_criteria>` 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)
