Merge branch 'worktree-agent-ac9c5343cdf410faf'
This commit is contained in:
commit
0b5fc36cd5
2 changed files with 725 additions and 0 deletions
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
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*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: components/phishing/action-area-card.tsx
|
||||
- FOUND: .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-SUMMARY.md
|
||||
- FOUND commit: 11190ab (Task 1)
|
||||
- FOUND commit: 1a7a592 (Task 2)
|
||||
- FOUND commit: e78f1b0 (SUMMARY)
|
||||
616
components/phishing/action-area-card.tsx
Normal file
616
components/phishing/action-area-card.tsx
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* ActionAreaCard — the only interactive remediation surface on the phishing
|
||||
* campaign review page (REVIEW-05, REVIEW-06). Renders one checkbox row per
|
||||
* recommended action with an always-visible, editable params form
|
||||
* pre-filled via deriveDefaultParams(), and submits exactly
|
||||
* ApproveActionInput[] to the existing /approve route.
|
||||
*
|
||||
* D-04: every action refetches via onActionComplete() on success — this
|
||||
* component never optimistically mutates local campaign state.
|
||||
*/
|
||||
|
||||
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, 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 {
|
||||
id: string;
|
||||
actionType: string;
|
||||
status: string;
|
||||
completedAt: string | null;
|
||||
approvedBy: string | null;
|
||||
}
|
||||
|
||||
export interface ActionAreaClassification {
|
||||
recommendedActions: string[];
|
||||
}
|
||||
|
||||
export interface ActionAreaEvidence {
|
||||
requesterEmail: string | null;
|
||||
senderEmail: string | null;
|
||||
senderDomain: string | null;
|
||||
messageId: string | null;
|
||||
}
|
||||
|
||||
interface ActionAreaCardProps {
|
||||
campaignId: string;
|
||||
classification: ActionAreaClassification | null;
|
||||
remediationActions: RemediationActionSummary[];
|
||||
campaignStatus: string;
|
||||
campaignUpdatedAt: string;
|
||||
evidence: ActionAreaEvidence;
|
||||
onActionComplete: () => void;
|
||||
}
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
function humanizeAction(actionType: string): string {
|
||||
return (
|
||||
ACTION_LABEL[actionType] ??
|
||||
actionType
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionRowState {
|
||||
checked: boolean;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a row's editable params back into the submit shape. The only
|
||||
* field that needs conversion is purge_message's `mailboxes` — edited as a
|
||||
* comma-separated string in the form, submitted as `string[]` to match
|
||||
* ApproveActionInput exactly.
|
||||
*/
|
||||
function normalizeParamsForSubmit(actionType: string, params: Record<string, unknown>): Record<string, unknown> {
|
||||
if (actionType === 'purge_message' && typeof params.mailboxes === 'string') {
|
||||
return {
|
||||
...params,
|
||||
mailboxes: params.mailboxes
|
||||
.split(',')
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function ActionParamsForm({
|
||||
actionType,
|
||||
params,
|
||||
onChange,
|
||||
}: {
|
||||
actionType: string;
|
||||
params: Record<string, unknown>;
|
||||
onChange: (params: Record<string, unknown>) => void;
|
||||
}) {
|
||||
function setField(key: string, value: string) {
|
||||
onChange({ ...params, [key]: value });
|
||||
}
|
||||
|
||||
const str = (key: string) => (typeof params[key] === 'string' ? (params[key] as string) : '');
|
||||
|
||||
switch (actionType) {
|
||||
case 'no_action':
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No parameters — informational verdict, no remediation needed.
|
||||
</p>
|
||||
);
|
||||
case 'warn_user':
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-recipientEmail`}>Recipient email</Label>
|
||||
<Input
|
||||
id={`${actionType}-recipientEmail`}
|
||||
value={str('recipientEmail')}
|
||||
onChange={(e) => setField('recipientEmail', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor={`${actionType}-message`}>Message</Label>
|
||||
<Textarea
|
||||
id={`${actionType}-message`}
|
||||
value={str('message')}
|
||||
onChange={(e) => setField('message', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'block_sender':
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-senderEmail`}>Sender email</Label>
|
||||
<Input
|
||||
id={`${actionType}-senderEmail`}
|
||||
value={str('senderEmail')}
|
||||
onChange={(e) => setField('senderEmail', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-senderDomain`}>Sender domain</Label>
|
||||
<Input
|
||||
id={`${actionType}-senderDomain`}
|
||||
value={str('senderDomain')}
|
||||
onChange={(e) => setField('senderDomain', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'purge_message':
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-messageId`}>Message ID</Label>
|
||||
<Input
|
||||
id={`${actionType}-messageId`}
|
||||
value={str('messageId')}
|
||||
onChange={(e) => setField('messageId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-mailboxes`}>Mailboxes</Label>
|
||||
<Input
|
||||
id={`${actionType}-mailboxes`}
|
||||
value={str('mailboxes')}
|
||||
onChange={(e) => setField('mailboxes', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter affected mailboxes, comma-separated</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'reset_password':
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-userPrincipalName`}>User principal name</Label>
|
||||
<Input
|
||||
id={`${actionType}-userPrincipalName`}
|
||||
value={str('userPrincipalName')}
|
||||
onChange={(e) => setField('userPrincipalName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'isolate_endpoint':
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-deviceId`}>Device ID</Label>
|
||||
<Input
|
||||
id={`${actionType}-deviceId`}
|
||||
value={str('deviceId')}
|
||||
onChange={(e) => setField('deviceId', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No device identifier available from evidence — enter manually
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'disable_forwarding_rule':
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-userPrincipalName`}>User principal name</Label>
|
||||
<Input
|
||||
id={`${actionType}-userPrincipalName`}
|
||||
value={str('userPrincipalName')}
|
||||
onChange={(e) => setField('userPrincipalName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${actionType}-ruleName`}>Rule name</Label>
|
||||
<Input
|
||||
id={`${actionType}-ruleName`}
|
||||
value={str('ruleName')}
|
||||
onChange={(e) => setField('ruleName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(',') ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!classification) {
|
||||
setRows({});
|
||||
return;
|
||||
}
|
||||
const next: Record<string, ActionRowState> = {};
|
||||
for (const actionType of classification.recommendedActions) {
|
||||
const params = deriveDefaultParams(actionType, evidence);
|
||||
if (actionType === 'purge_message' && Array.isArray(params.mailboxes)) {
|
||||
params.mailboxes = (params.mailboxes as string[]).join(', ');
|
||||
}
|
||||
next[actionType] = { checked: false, params };
|
||||
}
|
||||
setRows(next);
|
||||
// Re-derive only when the recommended-action list itself or the source
|
||||
// evidence changes — not on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
recommendedActionsKey,
|
||||
evidence.requesterEmail,
|
||||
evidence.senderEmail,
|
||||
evidence.senderDomain,
|
||||
evidence.messageId,
|
||||
]);
|
||||
|
||||
function updateRow(actionType: string, patch: Partial<ActionRowState>) {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[actionType]: { ...prev[actionType], ...patch },
|
||||
}));
|
||||
}
|
||||
|
||||
// NULL-GUARD FIRST: classification is Classification | null — the default
|
||||
// state for a freshly-grouped campaign, since classification is never
|
||||
// auto-triggered. Never dereference classification.recommendedActions
|
||||
// before this guard.
|
||||
if (classification == null || classification.recommendedActions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-bold">
|
||||
<ShieldAlert className="h-4 w-4 mr-2 inline" />
|
||||
Action Area
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No classification yet — classify this campaign to see recommended remediation actions.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// TS can't retain the null-guard's narrowing across the closures below
|
||||
// (handleApprove captures `classification` from the outer scope) — bind a
|
||||
// locally-narrowed const instead of asserting `!` at each use site.
|
||||
const activeClassification = classification;
|
||||
const checkedCount = Object.values(rows).filter((row) => row.checked).length;
|
||||
|
||||
async function handleApprove() {
|
||||
const actions = activeClassification.recommendedActions
|
||||
.filter((actionType) => rows[actionType]?.checked)
|
||||
.map((actionType) => ({
|
||||
actionType,
|
||||
params: normalizeParamsForSubmit(actionType, rows[actionType].params),
|
||||
}));
|
||||
|
||||
setIsApproving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/phishing/campaigns/${campaignId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actions }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Approve failed');
|
||||
toast.success(`Approved ${Array.isArray(data) ? data.length : actions.length} remediation action(s)`);
|
||||
onActionComplete();
|
||||
} catch (err) {
|
||||
toast.error(`Approve failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsApproving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
<CardTitle className="font-bold">
|
||||
<ShieldAlert className="h-4 w-4 mr-2 inline" />
|
||||
Action Area
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{classification.recommendedActions.map((actionType) => {
|
||||
const row = rows[actionType];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div key={actionType} className="rounded-md border p-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`action-${actionType}`}
|
||||
checked={row.checked}
|
||||
onCheckedChange={(checked) => updateRow(actionType, { checked: checked === true })}
|
||||
/>
|
||||
<Label htmlFor={`action-${actionType}`}>{humanizeAction(actionType)}</Label>
|
||||
</div>
|
||||
<ActionParamsForm
|
||||
actionType={actionType}
|
||||
params={row.params}
|
||||
onChange={(params) => updateRow(actionType, { params })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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