feat(22-05): remediate + mark-false-positive + permission/resolved gating (REVIEW-06, D-05, D-06)

- 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)
This commit is contained in:
lorentz 2026-07-16 14:42:05 -04:00
parent 11190abafd
commit 1a7a5922fe

View file

@ -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 <button>s don't reliably fire hover events, so
* the Tooltip trigger wraps a focusable <span> around the button rather than
* the button itself.
*/
function GatedButton({
children,
disabled,
reason,
loading,
onClick,
variant,
}: {
children: ReactNode;
disabled: boolean;
reason: string | null;
loading?: boolean;
onClick: () => void;
variant?: 'default' | 'outline' | 'destructive';
}) {
const button = (
<Button variant={variant} disabled={disabled || !!loading} onClick={onClick}>
{loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : null}
{children}
</Button>
);
if (!disabled || !reason) return button;
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} className="inline-block">
{button}
</span>
</TooltipTrigger>
<TooltipContent>{reason}</TooltipContent>
</Tooltip>
);
}
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<Record<string, ActionRowState>>({});
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 (
<Card>
<CardHeader>
@ -357,10 +519,97 @@ export function ActionAreaCard({ campaignId, classification, evidence, onActionC
})}
</div>
<Button disabled={isApproving || checkedCount === 0} onClick={handleApprove}>
{isApproving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Approve selected
</Button>
<TooltipProvider>
<div className="flex flex-wrap gap-2">
<GatedButton
disabled={!!approveDisabledReason}
reason={approveDisabledReason}
loading={isApproving}
onClick={handleApprove}
>
Approve selected
</GatedButton>
<GatedButton
disabled={!!remediateDisabledReason}
reason={remediateDisabledReason}
loading={isRemediating}
variant="outline"
onClick={() => setRemediateDialogOpen(true)}
>
Remediate approved actions
</GatedButton>
<GatedButton
disabled={!!markFalsePositiveDisabledReason}
reason={markFalsePositiveDisabledReason}
loading={isMarkingFalsePositive}
variant="destructive"
onClick={() => setFalsePositiveDialogOpen(true)}
>
Mark as false positive
</GatedButton>
</div>
</TooltipProvider>
<AlertDialog open={remediateDialogOpen} onOpenChange={setRemediateDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remediate approved actions?</AlertDialogTitle>
<AlertDialogDescription>
Remediate {approvedActions.length} approved action(s):{' '}
{approvedActions.map((a) => humanizeAction(a.actionType)).join(', ')}. This executes the
simulated remediation effect and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemediating}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: 'destructive' })}
disabled={isRemediating}
onClick={(e) => {
e.preventDefault();
void handleRemediateConfirm();
}}
>
Remediate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={falsePositiveDialogOpen} onOpenChange={setFalsePositiveDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark as false positive?</AlertDialogTitle>
<AlertDialogDescription>
Mark this campaign as a false positive? This cannot be undone there is no way to reverse it
later.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5">
<Label htmlFor="false-positive-reason">Reason (optional)</Label>
<Textarea
id="false-positive-reason"
value={falsePositiveReason}
onChange={(e) => setFalsePositiveReason(e.target.value)}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isMarkingFalsePositive}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: 'destructive' })}
disabled={isMarkingFalsePositive}
onClick={(e) => {
e.preventDefault();
void handleMarkFalsePositiveConfirm();
}}
>
Mark as false positive
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);