feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config, deleteC2Config, testC2Config, listCallbacks, executeC2) following the frozen M1+M2 backend contracts - frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult, C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse - frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config, useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2 - frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config card (url + write-only token + verify-tls + save/delete/test-connection), 503 disabled state, ConfirmDialog on delete - frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table (mono data cells), commands textarea pre-filled from rt.commands, Launch disabled until row selected + non-empty commands - frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit mode only, admin+redteam only (canEditEngagements gate) - frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT card, visible only when !isDone && canEditRT && hasC2Config; opens modal - Tests: 33 new tests across api/c2, components/C2ConfigCard, components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage (172 total, 139 baseline + 33 new, all passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
186
frontend/src/components/ExecuteViaC2Modal.tsx
Normal file
186
frontend/src/components/ExecuteViaC2Modal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
|
||||
import type { C2Callback } from '@/api/types';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface ExecuteViaC2ModalProps {
|
||||
simulationId: number;
|
||||
engagementId: number;
|
||||
initialCommands: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatCheckin(ts: string): string {
|
||||
// Show ISO timestamp as-is — it's a data cell (font-mono)
|
||||
return ts;
|
||||
}
|
||||
|
||||
export function ExecuteViaC2Modal({
|
||||
simulationId,
|
||||
engagementId,
|
||||
initialCommands,
|
||||
onClose,
|
||||
}: ExecuteViaC2ModalProps): JSX.Element {
|
||||
const { push } = useToast();
|
||||
|
||||
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
|
||||
const executeMutation = useExecuteC2(simulationId, engagementId);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [commands, setCommands] = useState(initialCommands);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? [];
|
||||
|
||||
const commandLines = commands
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const canLaunch = selectedId !== null && commandLines.length > 0;
|
||||
|
||||
const onLaunch = async () => {
|
||||
if (!canLaunch) return;
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const result = await executeMutation.mutateAsync({
|
||||
callback_display_id: selectedId,
|
||||
commands: commandLines,
|
||||
});
|
||||
push(`${result.tasks.length} task(s) submitted`, 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not execute via C2'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="c2-modal-title"
|
||||
data-testid="c2-modal"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
|
||||
|
||||
<div className="relative card-product w-full max-w-3xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
|
||||
<h2 id="c2-modal-title" className="text-[20px] font-medium text-ink">
|
||||
Execute via C2
|
||||
</h2>
|
||||
|
||||
{/* Callback table */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">Select callback</span>
|
||||
|
||||
{callbacksQuery.isLoading && (
|
||||
<p className="text-[14px] text-graphite">Loading callbacks…</p>
|
||||
)}
|
||||
|
||||
{callbacksQuery.isError && (
|
||||
<p className="text-[14px] text-bloom-deep">
|
||||
Could not load callbacks: {extractApiError(callbacksQuery.error, 'Unknown error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!callbacksQuery.isLoading && callbacks.length === 0 && !callbacksQuery.isError && (
|
||||
<p className="text-[14px] text-graphite">No callbacks available.</p>
|
||||
)}
|
||||
|
||||
{callbacks.length > 0 && (
|
||||
<div className="border border-hairline overflow-x-auto">
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">User</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{callbacks.map((cb) => {
|
||||
const isSelected = selectedId === cb.display_id;
|
||||
return (
|
||||
<tr
|
||||
key={cb.display_id}
|
||||
data-testid="c2-callback-row"
|
||||
onClick={() => setSelectedId(cb.display_id)}
|
||||
className={`cursor-pointer border-b border-hairline ${
|
||||
isSelected
|
||||
? 'bg-primary-soft'
|
||||
: 'hover:bg-cloud'
|
||||
}`}
|
||||
>
|
||||
<td className="px-md py-sm font-mono">{cb.display_id}</td>
|
||||
<td className="px-md py-sm">
|
||||
<span
|
||||
className={`badge-pill-${cb.active ? 'active' : 'inactive'}`}
|
||||
>
|
||||
{cb.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono">{cb.host}</td>
|
||||
<td className="px-md py-sm font-mono">{cb.user}</td>
|
||||
<td className="px-md py-sm font-mono">{cb.domain}</td>
|
||||
<td className="px-md py-sm font-mono">{formatCheckin(cb.last_checkin)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commands */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<label htmlFor="c2-commands" className="text-[14px] font-medium text-ink">
|
||||
Commands
|
||||
</label>
|
||||
<textarea
|
||||
id="c2-commands"
|
||||
data-testid="c2-commands-textarea"
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
className="text-input min-h-[112px] py-sm font-mono text-[14px]"
|
||||
placeholder="One command per line"
|
||||
/>
|
||||
<span className="text-[12px] text-graphite">
|
||||
{commandLines.length} command{commandLines.length !== 1 ? 's' : ''} — one task per line
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<p role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-md pt-xs">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-launch-btn"
|
||||
className="btn-primary"
|
||||
onClick={onLaunch}
|
||||
disabled={!canLaunch || executeMutation.isPending}
|
||||
>
|
||||
{executeMutation.isPending ? 'Launching…' : 'Launch'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={onClose}
|
||||
disabled={executeMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user