Files
mimic/frontend/src/components/ExecuteViaC2Modal.tsx

187 lines
6.7 KiB
TypeScript
Raw Normal View History

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>
);
}