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

241 lines
7.7 KiB
TypeScript
Raw Normal View History

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