248 lines
8.1 KiB
TypeScript
248 lines
8.1 KiB
TypeScript
import { useEffect, useState, type FormEvent } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
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 { t } = useTranslation();
|
|
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(false);
|
|
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(t('c2.config.toast.saved'), 'success');
|
|
setToken('');
|
|
setReplaceToken(false);
|
|
} catch (err) {
|
|
push(extractApiError(err, t('c2.config.error.save')), 'error');
|
|
}
|
|
};
|
|
|
|
const onDelete = async () => {
|
|
setShowDeleteConfirm(false);
|
|
setTestResult(null);
|
|
try {
|
|
await deleteMutation.mutateAsync();
|
|
push(t('c2.config.toast.deleted'), 'success');
|
|
setUrl('');
|
|
setToken('');
|
|
setVerifyTls(false);
|
|
setReplaceToken(false);
|
|
} catch (err) {
|
|
push(extractApiError(err, t('c2.config.error.delete')), 'error');
|
|
}
|
|
};
|
|
|
|
const onTest = async () => {
|
|
setTestResult(null);
|
|
try {
|
|
const result = await testMutation.mutateAsync();
|
|
setTestResult({
|
|
ok: result.ok,
|
|
message: result.ok ? t('c2.config.connected') : (result.error ?? t('c2.config.connectionFailed')),
|
|
});
|
|
} catch (err) {
|
|
setTestResult({ ok: false, message: extractApiError(err, t('c2.config.connectionFailed')) });
|
|
}
|
|
};
|
|
|
|
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">{t('c2.config.title')}</h2>
|
|
|
|
{is503 && (
|
|
<div
|
|
role="alert"
|
|
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
|
|
>
|
|
{t('c2.config.disabled')}
|
|
</div>
|
|
)}
|
|
|
|
{configQuery.isLoading ? (
|
|
<p className="text-[14px] text-graphite">{t('c2.config.loading')}</p>
|
|
) : (
|
|
<form onSubmit={onSave} noValidate className="flex flex-col gap-md">
|
|
<FormField
|
|
label={t('c2.config.field.url')}
|
|
htmlFor="c2-url"
|
|
hint={t('c2.config.field.urlHint')}
|
|
>
|
|
<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={t('c2.config.field.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}
|
|
>
|
|
{t('c2.config.btn.replaceToken')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<TextInput
|
|
id="c2-token"
|
|
data-testid="c2-token-input"
|
|
type="password"
|
|
name="api_token"
|
|
placeholder={config?.has_token ? t('c2.config.field.tokenHint') : t('c2.config.field.token')}
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
disabled={disabled}
|
|
/>
|
|
)}
|
|
</FormField>
|
|
|
|
<div className="flex flex-col gap-xs">
|
|
<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">
|
|
{t('c2.config.field.verifyTls')}
|
|
</label>
|
|
</div>
|
|
<p className="text-[12px] text-graphite">
|
|
{t('c2.config.field.verifyTlsHint')}
|
|
</p>
|
|
</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 ? t('c2.config.btn.saving') : t('c2.config.btn.save')}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
data-testid="c2-test-btn"
|
|
className="btn-outline"
|
|
onClick={onTest}
|
|
disabled={disabled || testMutation.isPending || !config}
|
|
>
|
|
{testMutation.isPending ? t('c2.config.btn.testing') : t('c2.config.btn.test')}
|
|
</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}
|
|
>
|
|
{t('c2.config.btn.delete')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{showDeleteConfirm && (
|
|
<ConfirmDialog
|
|
title={t('c2.config.deleteConfirm.title')}
|
|
description={t('c2.config.deleteConfirm.desc')}
|
|
confirmLabel={t('c2.config.deleteConfirm.confirm')}
|
|
cancelLabel={t('c2.config.deleteConfirm.cancel')}
|
|
destructive
|
|
onConfirm={onDelete}
|
|
onCancel={() => setShowDeleteConfirm(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|