---
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
plan: 05
type: execute
wave: 2
depends_on: ["22-01", "22-03"]
files_modified:
  - components/phishing/action-area-card.tsx
autonomous: true
requirements: [REVIEW-05, REVIEW-06]
must_haves:
  truths:
    - "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call (D-03)"
    - "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation) (D-04)"
    - "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces (D-06), or when the campaign is already resolved (D-05)"
    - "When classification is null (grouped-but-unclassified default state), ActionAreaCard renders an informational note and no action rows/buttons instead of dereferencing recommendedActions — it never crashes on the initial unclassified state"
  artifacts:
    - path: "components/phishing/action-area-card.tsx"
      provides: "the only interactive remediation surface (REVIEW-05, D-03..06)"
      exports: ["ActionAreaCard"]
  key_links:
    - from: "components/phishing/action-area-card.tsx"
      to: "/api/phishing/campaigns/{id}/approve|remediate|mark-false-positive"
      via: "fetch POST then onActionComplete refetch"
      pattern: "campaigns/.+/(approve|remediate|mark-false-positive)"
    - from: "components/phishing/action-area-card.tsx"
      to: "lib/permissions.ts hasPermission"
      via: "client-side gate mirroring the server route"
      pattern: "hasPermission"
---

<objective>
Build `ActionAreaCard` — the single interactive remediation surface (REVIEW-05). It renders a checkbox list of the latest classification's recommended actions with inline-editable pre-filled params (D-03), submits exactly `ApproveActionInput[]` to the existing approve route, wires remediate + mark-false-positive (with AlertDialog confirmations), refetches after every action (D-04, no optimistic mutation), and gates all three buttons with the exact same `hasPermission()` check the server enforces (D-06/REVIEW-06), disabling-with-tooltip rather than hiding for approve/remediate.

Purpose: This is the phase's only new write-triggering UI. Its permission gate must never diverge from the server, and its param submission must match `remediation-service.ts` exactly.
Output: 1 phishing component (2 tasks, same file).
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md

<interfaces>
From lib/services/remediation-default-params.ts (plan 01):
  deriveDefaultParams(actionType, evidence: { requesterEmail; senderEmail; senderDomain; messageId }): Record<string, unknown>

From lib/services/remediation-service.ts (existing — the exact submit shape):
  ApproveActionInput { actionType: string; params?: Record<string, unknown> }
  POST /approve body: { actions: ApproveActionInput[] }

From lib/permissions.ts (existing, isomorphic — safe client-side):
  hasPermission(roleName: string, resource: string, action: string): boolean
  phishing actions: read | analyze | approve | remediate

Existing write routes (reused verbatim — NOT modified):
  POST /api/phishing/campaigns/{id}/approve  -> 200 ApprovedRemediationAction[]
  POST /api/phishing/campaigns/{id}/remediate (no body) -> 200 { campaignId, actions:[{id,actionType,status,alreadyCompleted}] }
  POST /api/phishing/campaigns/{id}/mark-false-positive { reason? } -> 200 { campaignId, status:'false_positive', auditEventId }; 409 if approved/completed remediation exists

Props (from the review page, plan 06): { campaignId, classification (latest, with recommendedActions), remediationActions (with status + completedAt + approvedBy), campaignStatus, campaignUpdatedAt, evidence (requesterEmail/senderEmail/senderDomain/messageId derived from campaign detail), onActionComplete }

Analog to copy: components/rmm/rmm-dispatch-dialog.tsx (useSession + disabled-button + fetch-POST-toast). Tooltip primitive: components/ui/tooltip.tsx (plan 03). AlertDialog: components/ui/alert-dialog.tsx.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Checkbox list + editable params + Approve selected (D-03)</name>
  <files>components/phishing/action-area-card.tsx</files>
  <read_first>
    - components/rmm/rmm-dispatch-dialog.tsx (useSession + fetch-POST-toast structure to copy)
    - lib/services/remediation-default-params.ts (plan 01 — deriveDefaultParams)
    - components/ui/checkbox.tsx (shadcn checkbox usage)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — checkbox list, 7-row param table, "Approve selected" button rules)
  </read_first>
  <action>
    Create `components/phishing/action-area-card.tsx` (`'use client'`) exporting `ActionAreaCard(props)`. Wrap in a shadcn `<Card>` with `<CardTitle className="font-bold">` + leading lucide `ShieldAlert` (`h-4 w-4 mr-2 inline`). NULL-GUARD FIRST: the `classification` prop is `Classification | null`; the grouped-but-not-yet-classified campaign (the DEFAULT state, since classification is never auto-triggered) passes `null` here. Immediately after the card header, if `classification == null` (or `classification.recommendedActions` is empty), render an informational `text-sm text-muted-foreground` note ("No classification yet — classify this campaign to see recommended remediation actions.") and return early WITHOUT rendering the checkbox list or any action buttons. Never dereference `classification.recommendedActions` before this guard. Plan 06 additionally avoids rendering ActionAreaCard at all in the unclassified state, so this guard is defense-in-depth. When `classification != null`, render one row per string in `classification.recommendedActions`: a shadcn `<Checkbox>` with a humanized label (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"), plus an ALWAYS-VISIBLE params form beneath it (not gated on the checkbox) whose fields are pre-filled via `deriveDefaultParams(actionType, evidence)` and are editable per the UI-SPEC 7-row table: no_action renders "No parameters — informational verdict, no remediation needed." text instead of a form; warn_user→recipientEmail Input + message Textarea; block_sender→senderEmail + senderDomain Inputs; purge_message→messageId Input + mailboxes comma-separated Input (helper "Enter affected mailboxes, comma-separated"); reset_password→userPrincipalName Input; isolate_endpoint→deviceId Input (helper "No device identifier available from evidence — enter manually"); disable_forwarding_rule→userPrincipalName + ruleName Inputs. Hold per-row checked state and per-row edited params in `useState`. Add the "Approve selected" button (primary variant) that submits — enablement/tooltip gating comes in task 2, but wire the submit handler now: it POSTs `/api/phishing/campaigns/${campaignId}/approve` with body `{ actions: checkedRows.map(r => ({ actionType: r.actionType, params: r.currentParams })) }` (exactly ApproveActionInput[]; for purge_message parse the mailboxes comma-separated string into a string[] before submit), then on 2xx `toast.success` with the returned count and calls `props.onActionComplete()` (D-04 refetch — never mutate local state optimistically); on error `toast.error('Approve failed: {message}')`. Copy the fetch-POST-toast try/catch structure from rmm-dispatch-dialog.tsx.
  </action>
  <verify>
    <automated>grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx && grep -q "onActionComplete" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx` succeeds
    - A null-guard on `classification` precedes any `classification.recommendedActions` access (component returns an informational note, not a `TypeError`, when classification is null) — `grep -Eq "classification == null|!classification" components/phishing/action-area-card.tsx`
    - `grep -q "/approve" components/phishing/action-area-card.tsx` succeeds and the POST body uses `actions:` with `actionType`/`params`
    - `grep -q "onActionComplete" components/phishing/action-area-card.tsx` succeeds (refetch, not optimistic)
    - `npx tsc --noEmit --pretty` reports no error in this file
  </acceptance_criteria>
  <done>Checkbox list with editable pre-filled params submits exact ApproveActionInput[] and refetches on success.</done>
</task>

<task type="auto">
  <name>Task 2: Remediate + mark-false-positive + permission/resolved gating (REVIEW-06, D-05, D-06)</name>
  <files>components/phishing/action-area-card.tsx</files>
  <read_first>
    - components/phishing/action-area-card.tsx (task 1 output — extend the same file)
    - components/ui/alert-dialog.tsx (confirmation dialog usage)
    - components/ui/tooltip.tsx (plan 03 — disabled-button explanations)
    - lib/permissions.ts + lib/auth-client.ts (hasPermission + useSession)
    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — Buttons section, priority-ordered disable reasons, Copywriting Contract tooltips)
  </read_first>
  <action>
    Extend `action-area-card.tsx`. Derive role via `useSession()`: `const role = (session?.user as {role?:string})?.role ?? 'user'`; `const canApprove = hasPermission(role, 'phishing', 'approve')`; `const canRemediate = hasPermission(role, 'phishing', 'remediate')`. Define `resolved` = `campaignStatus === 'false_positive' || remediationActions.some(a => a.status === 'completed')`. Add two more buttons, all three ALWAYS rendered (never removed from DOM, D-05), each wrapped in a `Tooltip` when disabled. Evaluate each button's disabled reason in this priority order (first match wins, shown as the tooltip):
    "Approve selected" (primary): (1) !canApprove → "Requires approve permission"; (2) resolved → resolved-state copy; (3) zero checked → "Select at least one action to approve"; else enabled.
    "Remediate approved actions" (outline): (1) !canRemediate → "Requires remediate permission"; (2) resolved → resolved-state copy; (3) no remediationActions row with status==='approved' → "No approved actions to remediate"; else enabled — on click open an AlertDialog ("Remediate {N} approved action(s): {humanized types}. This executes the simulated remediation effect and cannot be undone." confirm label "Remediate", destructive variant) then POST `/api/phishing/campaigns/${campaignId}/remediate` (no body), toast + onActionComplete.
    "Mark as false positive" (destructive): (1) !canApprove → "Requires approve permission"; (2) resolved → resolved-state copy; (3) any remediationActions row with status in ('approved','completed') → "Cannot mark false positive — this campaign already has approved or completed remediation" (mirrors the server 409 guard exactly); else enabled — on click open an AlertDialog ("Mark this campaign as a false positive? This cannot be undone — there is no way to reverse it later." confirm label "Mark as false positive", destructive) with an optional reason Textarea, then POST `/api/phishing/campaigns/${campaignId}/mark-false-positive` with `{ reason? }`, toast + onActionComplete. Resolved-state tooltip copy: remediated → "Already remediated on {completedAt date} by {approvedBy}" (derive from the completed remediationActions row); false positive → "Marked as false positive on {campaignUpdatedAt date}". All permission checks use `hasPermission()` from lib/permissions.ts (never a bespoke `role === 'admin'` check) so client and server can never drift (REVIEW-06).
  </action>
  <verify>
    <automated>grep -q "hasPermission(role, 'phishing', 'approve')" components/phishing/action-area-card.tsx && grep -q "hasPermission(role, 'phishing', 'remediate')" components/phishing/action-area-card.tsx && grep -q "mark-false-positive" components/phishing/action-area-card.tsx && ! grep -q "role === 'admin'" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "hasPermission(role, 'phishing', 'approve')" components/phishing/action-area-card.tsx` and `...'remediate')` both succeed
    - `grep -c "role === 'admin'" components/phishing/action-area-card.tsx` returns 0 (no bespoke role check that could diverge from server)
    - `grep -q "/remediate" components/phishing/action-area-card.tsx` and `grep -q "/mark-false-positive" components/phishing/action-area-card.tsx` succeed
    - `grep -q "AlertDialog" components/phishing/action-area-card.tsx` succeeds (both destructive actions confirm)
    - All three buttons render regardless of state (disabled, not removed) — verified by the absence of a conditional that omits a button when `resolved` (reviewer/manual check)
    - `npx tsc --noEmit --pretty` reports no error in this file
  </acceptance_criteria>
  <done>All three actions gated by the exact server hasPermission check, disabled-with-tooltip when blocked, destructive actions confirmed, refetch on success.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser→API (write) | Operator triggers approve/remediate/mark-false-positive; the client gate is UX-only, the server route is the enforcement boundary |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-22-12 | Elevation of Privilege (unauthorized operator triggering a write) | action-area-card.tsx | mitigate | Client-side `hasPermission(role,'phishing','approve'|'remediate')` mirrors the server's `requirePermission` exactly (same function family); server remains the sole enforcement point — the client gate only prevents a failed request/UX confusion (REVIEW-06) |
| T-22-13 | Tampering (client/server permission drift) | action-area-card.tsx | mitigate | Uses `hasPermission()` from lib/permissions.ts — the identical statement the routes enforce — not a bespoke `role === 'admin'` string check (which could drift) |
| T-22-14 | Tampering (irreversible action mis-click) | remediate + mark-false-positive | mitigate | Both destructive transitions require an AlertDialog confirm before the POST; server-side idempotency (remediate) and the 409 guard (mark-false-positive) provide the real backstop |
| T-22-15 | Tampering (CSRF on write routes) | action-area-card.tsx | accept | Same-origin `fetch()` from a Better-Auth-session-cookie'd page, matching every existing write route's CSRF posture; no change introduced or required by this phase (22-RESEARCH Security Domain) |
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` clean
- `npm test` still green
- Manual: as a `user` role, confirm approve/remediate/mark-false-positive are disabled with the correct tooltip; as admin, confirm approve→remediate→resolved flow disables buttons post-resolution
</verification>

<success_criteria>
The action card submits exact ApproveActionInput[], wires remediate + mark-false-positive with confirmations and refetch, and disables-with-tooltip using the same permission function the server enforces.
</success_criteria>

<output>
Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-SUMMARY.md` when done
</output>
