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:
240
frontend/src/components/C2ConfigCard.tsx
Normal file
240
frontend/src/components/C2ConfigCard.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { FormField, TextInput } from './FormField';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface C2ConfigCardProps {
|
||||
engagementId: number;
|
||||
}
|
||||
|
||||
export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
const { push } = useToast();
|
||||
|
||||
const configQuery = useC2Config(engagementId);
|
||||
const updateMutation = useUpdateC2Config(engagementId);
|
||||
const deleteMutation = useDeleteC2Config(engagementId);
|
||||
const testMutation = useTestC2Config(engagementId);
|
||||
|
||||
const config = configQuery.data;
|
||||
const is503 = configQuery.error
|
||||
? (configQuery.error as { response?: { status?: number } })?.response?.status === 503
|
||||
: false;
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [verifyTls, setVerifyTls] = useState(true);
|
||||
const [replaceToken, setReplaceToken] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
// Sync URL and verifyTls from loaded config (but not token — write-only at API level)
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setUrl(config.url);
|
||||
setVerifyTls(config.verify_tls);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const disabled = is503 || configQuery.isLoading;
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (is503) return;
|
||||
setTestResult(null);
|
||||
|
||||
const input: { url: string; verify_tls: boolean; api_token?: string } = {
|
||||
url: url.trim(),
|
||||
verify_tls: verifyTls,
|
||||
};
|
||||
// Send token only if: no existing config, OR user explicitly chose to replace
|
||||
if (!config?.has_token || replaceToken) {
|
||||
if (token.trim()) input.api_token = token.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync(input);
|
||||
push('C2 configuration saved', 'success');
|
||||
setToken('');
|
||||
setReplaceToken(false);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not save C2 configuration'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setTestResult(null);
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
push('C2 configuration removed', 'success');
|
||||
setUrl('');
|
||||
setToken('');
|
||||
setVerifyTls(true);
|
||||
setReplaceToken(false);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not remove C2 configuration'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onTest = async () => {
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await testMutation.mutateAsync();
|
||||
setTestResult({
|
||||
ok: result.ok,
|
||||
message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'),
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: extractApiError(err, 'Test failed') });
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = updateMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="c2-config-card"
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<h2 className="text-[20px] font-medium text-ink">C2 configuration</h2>
|
||||
|
||||
{is503 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
|
||||
>
|
||||
C2 features are disabled (server has no encryption key configured).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<p className="text-[14px] text-graphite">Loading…</p>
|
||||
) : (
|
||||
<form onSubmit={onSave} noValidate className="flex flex-col gap-md">
|
||||
<FormField
|
||||
label="URL"
|
||||
htmlFor="c2-url"
|
||||
hint="HTTPS required (e.g. https://mythic.lab:7443)"
|
||||
>
|
||||
<TextInput
|
||||
id="c2-url"
|
||||
data-testid="c2-url-input"
|
||||
type="url"
|
||||
name="url"
|
||||
placeholder="https://mythic.lab:7443"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="API token" htmlFor="c2-token">
|
||||
{config?.has_token && !replaceToken ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<TextInput
|
||||
id="c2-token"
|
||||
data-testid="c2-token-input"
|
||||
type="password"
|
||||
name="api_token"
|
||||
placeholder="••••••••"
|
||||
value=""
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-[14px] whitespace-nowrap"
|
||||
onClick={() => setReplaceToken(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Replace token
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<TextInput
|
||||
id="c2-token"
|
||||
data-testid="c2-token-input"
|
||||
type="password"
|
||||
name="api_token"
|
||||
placeholder={config?.has_token ? 'Enter new token' : 'API token'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-sm">
|
||||
<input
|
||||
id="c2-verify-tls"
|
||||
data-testid="c2-verify-tls"
|
||||
type="checkbox"
|
||||
checked={verifyTls}
|
||||
onChange={(e) => setVerifyTls(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
|
||||
Verify TLS certificate
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-md flex-wrap">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="c2-save-btn"
|
||||
className="btn-primary"
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-test-btn"
|
||||
className="btn-outline"
|
||||
onClick={onTest}
|
||||
disabled={disabled || testMutation.isPending || !config}
|
||||
>
|
||||
{testMutation.isPending ? 'Testing…' : 'Test connection'}
|
||||
</button>
|
||||
|
||||
{testResult !== null && (
|
||||
<span
|
||||
className={`text-[14px] ${testResult.ok ? 'text-success' : 'text-bloom-deep'}`}
|
||||
>
|
||||
{testResult.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{config?.has_token && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-delete-btn"
|
||||
className="btn-text-link text-bloom-deep ml-auto"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
Delete configuration
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title="Delete C2 configuration"
|
||||
description="This will remove the C2 configuration for this engagement. The API token will be permanently deleted."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user