feat(22-05): checkbox list + editable params + Approve selected (D-03)
- Create ActionAreaCard with a null-guard on classification (renders an informational note, never dereferences recommendedActions, for the default grouped-but-unclassified state) - Render one checkbox row per recommended action with an always-visible params form pre-filled via deriveDefaultParams(actionType, evidence) - Submit exact ApproveActionInput[] to POST /approve; purge_message mailboxes is edited as a comma-separated string and normalized to string[] at submit time - Refetch via onActionComplete() on success (D-04, no optimistic mutation)
This commit is contained in:
parent
75cd454ff8
commit
11190abafd
1 changed files with 367 additions and 0 deletions
367
components/phishing/action-area-card.tsx
Normal file
367
components/phishing/action-area-card.tsx
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
'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 } 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 { toast } from 'sonner';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionAreaCard({ campaignId, classification, evidence, onActionComplete }: ActionAreaCardProps) {
|
||||||
|
const [rows, setRows] = useState<Record<string, ActionRowState>>({});
|
||||||
|
const [isApproving, setIsApproving] = useState(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Button disabled={isApproving || checkedCount === 0} onClick={handleApprove}>
|
||||||
|
{isApproving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||||
|
Approve selected
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue