docs(22-05): complete Action Area Card plan

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-07-16 14:43:35 -04:00
parent 1a7a5922fe
commit e78f1b0cdf

View file

@ -0,0 +1,101 @@
---
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*