From 1a7a5922fe2fa5e6aa95752c672f9124e48046d1 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 14:42:05 -0400 Subject: [PATCH] feat(22-05): remediate + mark-false-positive + permission/resolved gating (REVIEW-06, D-05, D-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GatedButton: buttons stay in the DOM (D-05) always, wrapped in a Tooltip explanation when disabled - Gate all three actions with hasPermission(role, 'phishing', 'approve'| 'remediate') from lib/permissions.ts — the identical check the server routes enforce, never a bespoke role === 'admin' string check (REVIEW-06) - Derive resolved = campaignStatus === 'false_positive' OR any completed remediation_actions row; resolved-state tooltip mirrors the completed action's approver/date or the campaign's updated_at - Remediate: AlertDialog confirmation listing approved action count/types, then POST /remediate (no body), refetch on success - Mark as false positive: AlertDialog with optional reason Textarea, then POST /mark-false-positive { reason? }; disabled reason mirrors the server's 409 guard exactly (approved/completed remediation blocks it) --- components/phishing/action-area-card.tsx | 263 ++++++++++++++++++++++- 1 file changed, 256 insertions(+), 7 deletions(-) diff --git a/components/phishing/action-area-card.tsx b/components/phishing/action-area-card.tsx index 7a8a08f..40fe6b5 100644 --- a/components/phishing/action-area-card.tsx +++ b/components/phishing/action-area-card.tsx @@ -11,15 +11,28 @@ * component never optimistically mutates local campaign state. */ -import { useEffect, useState } from 'react'; +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 } from '@/components/ui/button'; +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 { @@ -232,9 +245,70 @@ function ActionParamsForm({ } } -export function ActionAreaCard({ campaignId, classification, evidence, onActionComplete }: ActionAreaCardProps) { +/** + * A button that is ALWAYS rendered (D-05 — never removed from the DOM), and + * wrapped in a Tooltip explaining why it's disabled whenever `reason` is + * non-null. Disabled native + ); + + if (!disabled || !reason) return button; + + return ( + + + + {button} + + + {reason} + + ); +} + +export function ActionAreaCard({ + campaignId, + classification, + remediationActions, + campaignStatus, + campaignUpdatedAt, + evidence, + onActionComplete, +}: ActionAreaCardProps) { + const { data: session } = useSession(); + const role = (session?.user as { role?: string } | undefined)?.role ?? 'user'; + const canApprove = hasPermission(role, 'phishing', 'approve'); + const canRemediate = hasPermission(role, 'phishing', 'remediate'); + const [rows, setRows] = useState>({}); const [isApproving, setIsApproving] = useState(false); + const [isRemediating, setIsRemediating] = useState(false); + const [isMarkingFalsePositive, setIsMarkingFalsePositive] = useState(false); + const [remediateDialogOpen, setRemediateDialogOpen] = useState(false); + const [falsePositiveDialogOpen, setFalsePositiveDialogOpen] = useState(false); + const [falsePositiveReason, setFalsePositiveReason] = useState(''); const recommendedActionsKey = classification?.recommendedActions.join(',') ?? ''; @@ -324,6 +398,94 @@ export function ActionAreaCard({ campaignId, classification, evidence, onActionC } } + // D-05 "resolved" definition — a false-positive campaign or any completed + // remediation. Once resolved, all three buttons stay in the DOM but are + // disabled with a resolved-state tooltip. + const completedAction = remediationActions.find((a) => a.status === 'completed'); + const resolved = campaignStatus === 'false_positive' || completedAction != null; + + function resolvedTooltipCopy(): string { + if (completedAction) { + const dateStr = completedAction.completedAt + ? new Date(completedAction.completedAt).toLocaleDateString() + : 'an earlier date'; + return `Already remediated on ${dateStr} by ${completedAction.approvedBy ?? 'unknown'}`; + } + return `Marked as false positive on ${new Date(campaignUpdatedAt).toLocaleDateString()}`; + } + + const approvedActions = remediationActions.filter((a) => a.status === 'approved'); + const hasBlockingRemediation = remediationActions.some( + (a) => a.status === 'approved' || a.status === 'completed' + ); + + // Priority-ordered disable reasons — first match wins (UI-SPEC Action Area + // Spec, "Buttons" section). All permission checks use hasPermission() from + // lib/permissions.ts — the exact function the server routes enforce — + // never a bespoke role string check, so client and server can never drift + // (REVIEW-06). + const approveDisabledReason = !canApprove + ? 'Requires approve permission' + : resolved + ? resolvedTooltipCopy() + : checkedCount === 0 + ? 'Select at least one action to approve' + : null; + + const remediateDisabledReason = !canRemediate + ? 'Requires remediate permission' + : resolved + ? resolvedTooltipCopy() + : approvedActions.length === 0 + ? 'No approved actions to remediate' + : null; + + const markFalsePositiveDisabledReason = !canApprove + ? 'Requires approve permission' + : resolved + ? resolvedTooltipCopy() + : hasBlockingRemediation + ? 'Cannot mark false positive — this campaign already has approved or completed remediation' + : null; + + async function handleRemediateConfirm() { + setIsRemediating(true); + try { + const res = await fetch(`/api/phishing/campaigns/${campaignId}/remediate`, { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? data.error ?? 'Remediate failed'); + const count = Array.isArray(data.actions) ? data.actions.length : approvedActions.length; + toast.success(`Remediation completed for ${count} action(s)`); + setRemediateDialogOpen(false); + onActionComplete(); + } catch (err) { + toast.error(`Remediate failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + } finally { + setIsRemediating(false); + } + } + + async function handleMarkFalsePositiveConfirm() { + setIsMarkingFalsePositive(true); + try { + const res = await fetch(`/api/phishing/campaigns/${campaignId}/mark-false-positive`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(falsePositiveReason ? { reason: falsePositiveReason } : {}), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? data.error ?? 'Mark as false positive failed'); + toast.success('Campaign marked as false positive'); + setFalsePositiveDialogOpen(false); + setFalsePositiveReason(''); + onActionComplete(); + } catch (err) { + toast.error(`Mark as false positive failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + } finally { + setIsMarkingFalsePositive(false); + } + } + return ( @@ -357,10 +519,97 @@ export function ActionAreaCard({ campaignId, classification, evidence, onActionC })} - + +
+ + Approve selected + + + setRemediateDialogOpen(true)} + > + Remediate approved actions + + + setFalsePositiveDialogOpen(true)} + > + Mark as false positive + +
+
+ + + + + Remediate approved actions? + + Remediate {approvedActions.length} approved action(s):{' '} + {approvedActions.map((a) => humanizeAction(a.actionType)).join(', ')}. This executes the + simulated remediation effect and cannot be undone. + + + + Cancel + { + e.preventDefault(); + void handleRemediateConfirm(); + }} + > + Remediate + + + + + + + + + Mark as false positive? + + Mark this campaign as a false positive? This cannot be undone — there is no way to reverse it + later. + + +
+ +