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:
parent
11190abafd
commit
1a7a5922fe
1 changed files with 256 additions and 7 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue