Files
Charon/frontend/src/pages/CrowdSecConfig.tsx
GitHub Actions 8511d98160 fix(i18n): translations
- Added translation support using react-i18next in WafConfig and CrowdSecConfig components.
- Updated UI elements to use translation keys instead of hardcoded strings.
- Enhanced test coverage for i18n integration, including mocks for translation in tests.
- Fixed various test cases to align with new translation structure.
- Created a QA report for i18n implementation, noting validation of translation files and areas for improvement.
2025-12-19 18:55:49 +00:00

1251 lines
57 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { isAxiosError } from 'axios'
import { useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Button } from '../components/ui/Button'
import { Card } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
import { getSecurityStatus } from '../api/security'
import { getFeatureFlags } from '../api/featureFlags'
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, CrowdSecDecision, statusCrowdsec, CrowdSecStatus, startCrowdsec } from '../api/crowdsec'
import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets'
import { createBackup } from '../api/backups'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../utils/toast'
import { ConfigReloadOverlay } from '../components/LoadingStates'
import { Shield, ShieldOff, Trash2, Search, AlertTriangle, ExternalLink } from 'lucide-react'
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
import { CROWDSEC_PRESETS, CrowdsecPreset } from '../data/crowdsecPresets'
import { useConsoleStatus, useEnrollConsole, useClearConsoleEnrollment } from '../hooks/useConsoleEnrollment'
export default function CrowdSecConfig() {
const { t } = useTranslation()
const { data: status, isLoading, error } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<'alpha' | 'type' | 'source'>('alpha')
const [file, setFile] = useState<File | null>(null)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string | null>(null)
const [selectedPresetSlug, setSelectedPresetSlug] = useState<string>('')
const [showBanModal, setShowBanModal] = useState(false)
const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' })
const [confirmUnban, setConfirmUnban] = useState<CrowdSecDecision | null>(null)
const [isApplyingPreset, setIsApplyingPreset] = useState(false)
const [presetPreview, setPresetPreview] = useState<string>('')
const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null)
const [presetStatusMessage, setPresetStatusMessage] = useState<string | null>(null)
const [hubUnavailable, setHubUnavailable] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: boolean; usedCscli?: boolean; cacheKey?: string } | null>(null)
const queryClient = useQueryClient()
const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled'
// Note: CrowdSec mode is now controlled via Security Dashboard toggle
const { data: featureFlags } = useQuery({ queryKey: ['feature-flags'], queryFn: getFeatureFlags })
const consoleEnrollmentEnabled = Boolean(featureFlags?.['feature.crowdsec.console_enrollment'])
const [enrollmentToken, setEnrollmentToken] = useState('')
const [consoleTenant, setConsoleTenant] = useState('')
const [consoleAgentName, setConsoleAgentName] = useState((typeof window !== 'undefined' && window.location?.hostname) || 'charon-agent')
const [consoleAck, setConsoleAck] = useState(false)
const [consoleErrors, setConsoleErrors] = useState<{ token?: string; agent?: string; tenant?: string; ack?: string; submit?: string }>({})
const consoleStatusQuery = useConsoleStatus(consoleEnrollmentEnabled)
const enrollConsoleMutation = useEnrollConsole()
const clearEnrollmentMutation = useClearConsoleEnrollment()
const [showReenrollForm, setShowReenrollForm] = useState(false)
const navigate = useNavigate()
const [initialCheckComplete, setInitialCheckComplete] = useState(false)
// Add initial delay to avoid false negative when LAPI is starting
useEffect(() => {
if (consoleEnrollmentEnabled && !initialCheckComplete) {
const timer = setTimeout(() => {
setInitialCheckComplete(true)
}, 3000) // Wait 3 seconds before first check
return () => clearTimeout(timer)
}
}, [consoleEnrollmentEnabled, initialCheckComplete])
// Add LAPI status check with polling
const lapiStatusQuery = useQuery<CrowdSecStatus>({
queryKey: ['crowdsec-lapi-status'],
queryFn: statusCrowdsec,
enabled: consoleEnrollmentEnabled && initialCheckComplete,
refetchInterval: 5000, // Poll every 5 seconds
retry: false,
})
const backupMutation = useMutation({ mutationFn: () => createBackup() })
const importMutation = useMutation({
mutationFn: async (file: File) => {
return await importCrowdsecConfig(file)
},
onSuccess: () => {
toast.success('CrowdSec config imported (backup created)')
queryClient.invalidateQueries({ queryKey: ['security-status'] })
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to import')
}
})
const listMutation = useQuery({ queryKey: ['crowdsec-files'], queryFn: listCrowdsecFiles })
const readMutation = useMutation({ mutationFn: (path: string) => readCrowdsecFile(path), onSuccess: (data) => setFileContent(data.content) })
const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } })
const presetsQuery = useQuery({
queryKey: ['crowdsec-presets'],
queryFn: listCrowdsecPresets,
enabled: !!status?.crowdsec,
retry: false,
})
type PresetCatalogEntry = CrowdsecPreset & {
requiresHub: boolean
available?: boolean
cached?: boolean
cacheKey?: string
etag?: string
retrievedAt?: string
source?: string
}
const presetCatalog: PresetCatalogEntry[] = useMemo(() => {
const remotePresets = presetsQuery.data?.presets
const localMap = new Map(CROWDSEC_PRESETS.map((preset) => [preset.slug, preset]))
if (remotePresets?.length) {
return remotePresets.map((preset) => {
const local = localMap.get(preset.slug)
return {
slug: preset.slug,
title: preset.title || local?.title || preset.slug,
description: local?.description || preset.summary,
content: local?.content || '',
tags: local?.tags || preset.tags,
warning: local?.warning,
requiresHub: Boolean(preset.requires_hub),
available: preset.available,
cached: preset.cached,
cacheKey: preset.cache_key,
etag: preset.etag,
retrievedAt: preset.retrieved_at,
source: preset.source,
}
})
}
return CROWDSEC_PRESETS.map((preset) => ({ ...preset, requiresHub: false, available: true, cached: false, source: 'charon-curated' }))
}, [presetsQuery.data])
const filteredPresets = useMemo(() => {
let result = [...presetCatalog]
if (searchQuery) {
const query = searchQuery.toLowerCase()
result = result.filter(
(p) =>
p.title.toLowerCase().includes(query) ||
p.description?.toLowerCase().includes(query) ||
p.slug.toLowerCase().includes(query)
)
}
result.sort((a, b) => {
if (sortBy === 'alpha') {
return a.title.localeCompare(b.title)
}
if (sortBy === 'source') {
const sourceA = a.source || 'z'
const sourceB = b.source || 'z'
if (sourceA !== sourceB) return sourceA.localeCompare(sourceB)
return a.title.localeCompare(b.title)
}
return a.title.localeCompare(b.title)
})
return result
}, [presetCatalog, searchQuery, sortBy])
useEffect(() => {
if (!presetCatalog.length) return
if (!selectedPresetSlug || !presetCatalog.some((preset) => preset.slug === selectedPresetSlug)) {
setSelectedPresetSlug(presetCatalog[0].slug)
}
}, [presetCatalog, selectedPresetSlug])
useEffect(() => {
if (consoleStatusQuery.data?.agent_name) {
setConsoleAgentName((prev) => prev || consoleStatusQuery.data?.agent_name || prev)
}
if (consoleStatusQuery.data?.tenant) {
setConsoleTenant((prev) => prev || consoleStatusQuery.data?.tenant || prev)
}
}, [consoleStatusQuery.data?.agent_name, consoleStatusQuery.data?.tenant])
const selectedPreset = presetCatalog.find((preset) => preset.slug === selectedPresetSlug)
const selectedPresetRequiresHub = selectedPreset?.requiresHub ?? false
const pullPresetMutation = useMutation({
mutationFn: (slug: string) => pullCrowdsecPreset(slug),
onSuccess: (data) => {
setPresetPreview(data.preview)
setPresetMeta({ cacheKey: data.cache_key, etag: data.etag, retrievedAt: data.retrieved_at, source: data.source })
setPresetStatusMessage('Preview fetched from hub')
setHubUnavailable(false)
setValidationError(null)
},
onError: (err: unknown) => {
setPresetStatusMessage(null)
if (isAxiosError(err)) {
if (err.response?.status === 400) {
setValidationError(err.response.data?.error || 'Preset slug is invalid')
return
}
if (err.response?.status === 503) {
setHubUnavailable(true)
setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.')
return
}
setPresetStatusMessage(err.response?.data?.error || 'Failed to pull preset preview')
} else {
setPresetStatusMessage('Failed to pull preset preview')
}
},
})
useEffect(() => {
if (!selectedPreset) return
setValidationError(null)
setPresetStatusMessage(null)
setApplyInfo(null)
setPresetMeta({
cacheKey: selectedPreset.cacheKey,
etag: selectedPreset.etag,
retrievedAt: selectedPreset.retrievedAt,
source: selectedPreset.source,
})
setPresetPreview(selectedPreset.content || '')
pullPresetMutation.mutate(selectedPreset.slug)
}, [selectedPreset?.slug])
const loadCachedPreview = async () => {
if (!selectedPreset) return
try {
const cached = await getCrowdsecPresetCache(selectedPreset.slug)
setPresetPreview(cached.preview)
setPresetMeta({ cacheKey: cached.cache_key, etag: cached.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source })
setPresetStatusMessage('Loaded cached preview')
setHubUnavailable(false)
} catch (err) {
const msg = isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load cached preview'
toast.error(msg)
}
}
const normalizedConsoleStatus = consoleStatusQuery.data?.status === 'failed' ? 'degraded' : consoleStatusQuery.data?.status || 'not_enrolled'
const isConsoleDegraded = normalizedConsoleStatus === 'degraded'
const isConsolePending = enrollConsoleMutation.isPending || normalizedConsoleStatus === 'enrolling'
const isConsolePendingAcceptance = normalizedConsoleStatus === 'pending_acceptance'
const consoleStatusLabel = normalizedConsoleStatus.replace('_', ' ')
const consoleTokenState = consoleStatusQuery.data ? (consoleStatusQuery.data.key_present ? 'Stored (masked)' : 'Not stored') : '—'
const canRotateKey = normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'degraded' || isConsolePendingAcceptance
const consoleDocsHref = 'https://wikid82.github.io/charon/security/'
const sanitizeSecret = (msg: string) => msg.replace(/\b[A-Za-z0-9]{10,64}\b/g, '***')
const sanitizeErrorMessage = (err: unknown) => {
if (isAxiosError(err)) {
return sanitizeSecret(err.response?.data?.error || err.message)
}
if (err instanceof Error) return sanitizeSecret(err.message)
return 'Console enrollment failed'
}
const validateConsoleEnrollment = (options?: { allowMissingTenant?: boolean; requireAck?: boolean }) => {
const nextErrors: { token?: string; agent?: string; tenant?: string; ack?: string } = {}
if (!enrollmentToken.trim()) {
nextErrors.token = 'Enrollment token is required'
}
if (!consoleAgentName.trim()) {
nextErrors.agent = 'Agent name is required'
}
if (!consoleTenant.trim() && !options?.allowMissingTenant) {
nextErrors.tenant = 'Tenant / organization is required'
}
if (options?.requireAck && !consoleAck) {
nextErrors.ack = 'You must acknowledge the console data-sharing notice'
}
setConsoleErrors(nextErrors)
return Object.keys(nextErrors).length === 0
}
const submitConsoleEnrollment = async (force = false) => {
const allowMissingTenant = force && !consoleTenant.trim()
const requireAck = normalizedConsoleStatus === 'not_enrolled'
if (!validateConsoleEnrollment({ allowMissingTenant, requireAck })) return
const tenantValue = consoleTenant.trim() || consoleStatusQuery.data?.tenant || consoleAgentName || 'charon-agent'
try {
await enrollConsoleMutation.mutateAsync({
enrollment_key: enrollmentToken.trim(),
tenant: tenantValue,
agent_name: consoleAgentName.trim(),
force,
})
setConsoleErrors({})
setEnrollmentToken('')
setShowReenrollForm(false)
if (!consoleTenant.trim()) {
setConsoleTenant(tenantValue)
}
toast.success(
force
? 'Enrollment submitted! Accept the request on app.crowdsec.net to complete.'
: 'Enrollment request sent! Accept the enrollment on app.crowdsec.net to complete registration.'
)
} catch (err) {
const message = sanitizeErrorMessage(err)
setConsoleErrors((prev) => ({ ...prev, submit: message }))
toast.error(message)
}
}
// Banned IPs queries and mutations
const decisionsQuery = useQuery({
queryKey: ['crowdsec-decisions'],
queryFn: listCrowdsecDecisions,
enabled: isLocalMode,
})
const banMutation = useMutation({
mutationFn: () => banIP(banForm.ip, banForm.duration, banForm.reason),
onSuccess: () => {
toast.success(`IP ${banForm.ip} has been banned`)
queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] })
setShowBanModal(false)
setBanForm({ ip: '', duration: '24h', reason: '' })
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to ban IP')
},
})
const unbanMutation = useMutation({
mutationFn: (ip: string) => unbanIP(ip),
onSuccess: (_, ip) => {
toast.success(`IP ${ip} has been unbanned`)
queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] })
setConfirmUnban(null)
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to unban IP')
},
})
const handleExport = async () => {
const defaultName = buildCrowdsecExportFilename()
const filename = promptCrowdsecFilename(defaultName)
if (!filename) return
try {
const blob = await exportCrowdsecConfig()
downloadCrowdsecExport(blob, filename)
toast.success('CrowdSec configuration exported')
} catch {
toast.error('Failed to export CrowdSec configuration')
}
}
const handleImport = async () => {
if (!file) return
try {
await backupMutation.mutateAsync()
await importMutation.mutateAsync(file)
setFile(null)
} catch {
// handled in onError
}
}
const handleReadFile = async (path: string) => {
setSelectedPath(path)
await readMutation.mutateAsync(path)
}
const handleSaveFile = async () => {
if (!selectedPath || fileContent === null) return
try {
await backupMutation.mutateAsync()
await writeMutation.mutateAsync({ path: selectedPath, content: fileContent })
} catch {
// handled
}
}
const applyPresetLocally = async (reason?: string) => {
if (!selectedPreset) {
toast.error('Select a preset to apply')
return
}
const targetPath = selectedPath ?? listMutation.data?.files?.[0]
if (!targetPath) {
toast.error('Select a configuration file to apply the preset')
return
}
const content = presetPreview || selectedPreset.content
if (!content) {
toast.error('Preset preview is unavailable; retry pulling before applying')
return
}
try {
await backupMutation.mutateAsync()
await writeCrowdsecFile(targetPath, content)
queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] })
setSelectedPath(targetPath)
setFileContent(content)
setApplyInfo({ status: 'applied-locally', cacheKey: presetMeta?.cacheKey })
toast.success(reason ? `${reason}: preset applied locally` : 'Preset applied locally (backup created)')
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to apply preset locally'
toast.error(msg)
}
}
const handleApplyPreset = async () => {
if (!selectedPreset) {
toast.error('Select a preset to apply')
return
}
setIsApplyingPreset(true)
setApplyInfo(null)
setValidationError(null)
try {
const res = await applyCrowdsecPreset({ slug: selectedPreset.slug, cache_key: presetMeta?.cacheKey })
setApplyInfo({
status: res.status,
backup: res.backup,
reloadHint: res.reload_hint,
usedCscli: res.used_cscli,
cacheKey: res.cache_key,
})
const reloadNote = res.reload_hint ? ' (reload required)' : ''
toast.success(`Preset applied via backend${reloadNote}`)
if (res.backup) {
setPresetStatusMessage(`Backup stored at ${res.backup}`)
}
} catch (err) {
if (isAxiosError(err)) {
if (err.response?.status === 501) {
toast.info('Preset apply is not available on the server; applying locally instead')
await applyPresetLocally('Backend apply unavailable')
return
}
if (err.response?.status === 400) {
setValidationError(err.response?.data?.error || 'Preset validation failed')
toast.error('Preset validation failed')
return
}
if (err.response?.status === 503) {
setHubUnavailable(true)
setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.')
toast.error('Hub unavailable; retry pull/apply or use cached copy')
return
}
const errorMsg = err.response?.data?.error || err.message
const backupPath = (err.response?.data as { backup?: string })?.backup
// Check if error is due to missing cache
if (errorMsg.includes('not cached') || errorMsg.includes('Pull the preset first')) {
toast.error(errorMsg)
setValidationError('Preset must be pulled before applying. Click "Pull Preview" first.')
return
}
if (backupPath) {
setApplyInfo({ status: 'failed', backup: backupPath, cacheKey: presetMeta?.cacheKey })
toast.error(`Apply failed: ${errorMsg}. Backup created at ${backupPath}`)
return
}
toast.error(`Apply failed: ${errorMsg}`)
} else {
toast.error('Failed to apply preset')
}
} finally {
setIsApplyingPreset(false)
}
}
const presetActionDisabled =
!selectedPreset ||
isApplyingPreset ||
backupMutation.isPending ||
pullPresetMutation.isPending ||
(selectedPresetRequiresHub && hubUnavailable)
// Determine if any operation is in progress
const isApplyingConfig =
importMutation.isPending ||
writeMutation.isPending ||
backupMutation.isPending ||
pullPresetMutation.isPending ||
isApplyingPreset ||
banMutation.isPending ||
unbanMutation.isPending
// Determine contextual message
const getMessage = () => {
if (pullPresetMutation.isPending) {
return { message: 'Fetching preset...', submessage: 'Pulling preview from CrowdSec Hub' }
}
if (isApplyingPreset) {
return { message: 'Loading preset...', submessage: 'Applying curated preset with backup' }
}
if (importMutation.isPending) {
return { message: 'Summoning the guardian...', submessage: 'Importing CrowdSec configuration' }
}
if (writeMutation.isPending) {
return { message: 'Guardian inscribes...', submessage: 'Saving configuration file' }
}
if (banMutation.isPending) {
return { message: 'Guardian raises shield...', submessage: 'Banning IP address' }
}
if (unbanMutation.isPending) {
return { message: 'Guardian lowers shield...', submessage: 'Unbanning IP address' }
}
return { message: 'Strengthening the guard...', submessage: 'Configuration in progress' }
}
const { message, submessage } = getMessage()
if (isLoading) return <div className="p-8 text-center text-white">{t('crowdsecConfig.loading')}</div>
if (error) return <div className="p-8 text-center text-red-500">{t('crowdsecConfig.loadError')}: {(error as Error).message}</div>
if (!status) return <div className="p-8 text-center text-gray-400">{t('crowdsecConfig.noStatus')}</div>
if (!status.crowdsec) return <div className="p-8 text-center text-red-500">{t('crowdsecConfig.noConfig')}</div>
return (
<>
{isApplyingConfig && (
<ConfigReloadOverlay
message={message}
submessage={submessage}
type="cerberus"
/>
)}
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('crowdsecConfig.title')}</h1>
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4 mb-4">
<p className="text-sm text-blue-200">
<strong>{t('crowdsecConfig.note')}:</strong> {t('crowdsecConfig.noteText')}{' '}
<Link to="/security" className="text-blue-400 hover:text-blue-300 underline">{t('navigation.security')}</Link>.
</p>
</div>
{consoleEnrollmentEnabled && (
<Card data-testid="console-enrollment-card">
<div className="space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1">
<h3 className="text-md font-semibold">{t('crowdsecConfig.consoleEnrollment.title')}</h3>
<p className="text-sm text-gray-400">{t('crowdsecConfig.consoleEnrollment.description')}</p>
<p className="text-xs text-gray-500">
{t('crowdsecConfig.consoleEnrollment.privacyNote')}
<a className="text-blue-400 hover:underline ml-1" href={consoleDocsHref} target="_blank" rel="noreferrer">{t('common.docs')}</a>
</p>
</div>
<div className="text-right text-sm text-gray-300 space-y-1">
<p className="font-semibold text-white capitalize" data-testid="console-status-label">{t('common.status')}: {consoleStatusLabel}</p>
<p className="text-xs text-gray-500">{t('crowdsecConfig.consoleEnrollment.lastHeartbeat')}: {consoleStatusQuery.data?.last_heartbeat_at ? new Date(consoleStatusQuery.data.last_heartbeat_at).toLocaleString() : '—'}</p>
</div>
</div>
{consoleStatusQuery.data?.last_error && (
<p className="text-xs text-yellow-300" data-testid="console-status-error">{t('crowdsecConfig.consoleEnrollment.lastError')}: {sanitizeSecret(consoleStatusQuery.data.last_error)}</p>
)}
{consoleErrors.submit && (
<p className="text-sm text-red-400" data-testid="console-enroll-error">{consoleErrors.submit}</p>
)}
{/* Yellow warning: Process running but LAPI initializing */}
{lapiStatusQuery.data && lapiStatusQuery.data.running && !lapiStatusQuery.data.lapi_ready && initialCheckComplete && (
<div className="flex items-start gap-3 p-4 bg-yellow-900/20 border border-yellow-700/50 rounded-lg" data-testid="lapi-warning">
<AlertTriangle className="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-yellow-200 font-medium mb-2">
{t('crowdsecConfig.lapiInitializing')}
</p>
<p className="text-xs text-yellow-300 mb-3">
{t('crowdsecConfig.lapiInitializingDesc')}
{lapiStatusQuery.isRefetching && ` ${t('crowdsecConfig.checkingAgain')}`}
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => lapiStatusQuery.refetch()}
disabled={lapiStatusQuery.isRefetching}
>
Check Now
</Button>
</div>
</div>
</div>
)}
{/* Red warning: Process not running at all */}
{lapiStatusQuery.data && !lapiStatusQuery.data.running && initialCheckComplete && (
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-700/50 rounded-lg" data-testid="lapi-not-running-warning">
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-200 font-medium mb-2">
{t('crowdsecConfig.notRunning')}
</p>
<p className="text-xs text-red-300 mb-3">
{t('crowdsecConfig.notRunningDesc')}
</p>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={async () => {
try {
await startCrowdsec();
toast.info(t('crowdsecConfig.starting'));
// Refetch status after a delay to allow startup
setTimeout(() => {
lapiStatusQuery.refetch();
}, 3000);
} catch {
toast.error(t('crowdsecConfig.startFailed'));
}
}}
>
{t('crowdsecConfig.startCrowdsec')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => lapiStatusQuery.refetch()}
disabled={lapiStatusQuery.isRefetching}
>
Check Now
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => navigate('/security')}
>
{t('crowdsecConfig.goToSecurity')}
</Button>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
label={t('crowdsecConfig.consoleEnrollment.enrollToken')}
type="password"
value={enrollmentToken}
onChange={(e) => setEnrollmentToken(e.target.value)}
placeholder={t('crowdsecConfig.consoleEnrollment.tokenPlaceholder')}
helperText={t('crowdsecConfig.consoleEnrollment.tokenHelper')}
error={consoleErrors.token}
errorTestId="console-enroll-error"
data-testid="console-enrollment-token"
/>
<Input
label={t('crowdsecConfig.consoleEnrollment.agentName')}
value={consoleAgentName}
onChange={(e) => setConsoleAgentName(e.target.value)}
error={consoleErrors.agent}
errorTestId="console-enroll-error"
data-testid="console-agent-name"
/>
<Input
label={t('crowdsecConfig.consoleEnrollment.tenant')}
value={consoleTenant}
onChange={(e) => setConsoleTenant(e.target.value)}
helperText={t('crowdsecConfig.consoleEnrollment.tenantHelper')}
error={consoleErrors.tenant}
errorTestId="console-enroll-error"
data-testid="console-tenant"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 accent-blue-500"
checked={consoleAck}
onChange={(e) => setConsoleAck(e.target.checked)}
disabled={isConsolePending}
data-testid="console-ack-checkbox"
/>
<span className="text-sm text-gray-400">{t('crowdsecConfig.consoleEnrollment.ackText')}</span>
</div>
{consoleErrors.ack && <p className="text-sm text-red-400" data-testid="console-enroll-error">{consoleErrors.ack}</p>}
<div className="flex flex-wrap gap-2">
<Button
onClick={() => submitConsoleEnrollment(false)}
disabled={isConsolePending || (lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready) || !enrollmentToken.trim()}
isLoading={enrollConsoleMutation.isPending}
data-testid="console-enroll-btn"
title={
lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready
? t('crowdsecConfig.consoleEnrollment.lapiMustBeReady')
: !enrollmentToken.trim()
? t('crowdsecConfig.consoleEnrollment.tokenRequired')
: undefined
}
>
{t('crowdsecConfig.consoleEnrollment.enroll')}
</Button>
<Button
variant="secondary"
onClick={() => submitConsoleEnrollment(true)}
disabled={isConsolePending || !canRotateKey || (lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready)}
isLoading={enrollConsoleMutation.isPending}
data-testid="console-rotate-btn"
title={
lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready
? t('crowdsecConfig.consoleEnrollment.lapiMustBeReadyRotate')
: undefined
}
>
{t('crowdsecConfig.consoleEnrollment.rotateKey')}
</Button>
{isConsoleDegraded && (
<Button
variant="secondary"
onClick={() => submitConsoleEnrollment(true)}
disabled={isConsolePending || (lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready)}
isLoading={enrollConsoleMutation.isPending}
data-testid="console-retry-btn"
title={
lapiStatusQuery.data && !lapiStatusQuery.data.lapi_ready
? t('crowdsecConfig.consoleEnrollment.lapiMustBeReadyRetry')
: undefined
}
>
{t('crowdsecConfig.consoleEnrollment.retryEnrollment')}
</Button>
)}
</div>
{/* Info box for pending acceptance status */}
{isConsolePendingAcceptance && (
<div className="bg-blue-900/30 border border-blue-500 rounded-lg p-4" data-testid="pending-acceptance-info">
<p className="text-sm text-blue-200">
<strong>{t('crowdsecConfig.consoleEnrollment.actionRequired')}:</strong> {t('crowdsecConfig.consoleEnrollment.pendingAcceptanceText')}{' '}
<a
href="https://app.crowdsec.net"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-100"
>
app.crowdsec.net
</a>.
{t('crowdsecConfig.consoleEnrollment.pendingAcceptanceNote')}
</p>
</div>
)}
{/* Re-enrollment Section - shown when enrolled or pending */}
{(normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'pending_acceptance') && (
<div className="border border-blue-500/30 bg-blue-500/5 rounded-lg p-4" data-testid="reenroll-section">
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-100">{t('crowdsecConfig.reenroll.title')}</h3>
<p className="text-sm text-gray-400 mt-1">
{t('crowdsecConfig.reenroll.description')}
</p>
</div>
</div>
{!showReenrollForm ? (
<div className="flex flex-wrap gap-3">
<a
href="https://app.crowdsec.net/security-engines"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-400 hover:text-blue-300 text-sm"
>
<ExternalLink className="w-4 h-4" />
{t('crowdsecConfig.reenroll.getNewKey')}
</a>
<Button
variant="secondary"
size="sm"
onClick={() => setShowReenrollForm(true)}
data-testid="show-reenroll-form-btn"
>
{t('crowdsecConfig.reenroll.withNewKey')}
</Button>
</div>
) : (
<div className="space-y-4 pt-2 border-t border-gray-700">
{/* Re-enrollment form */}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{t('crowdsecConfig.reenroll.newEnrollmentKey')}
</label>
<Input
type="text"
value={enrollmentToken}
onChange={(e) => setEnrollmentToken(e.target.value)}
placeholder={t('crowdsecConfig.reenroll.keyPlaceholder')}
data-testid="reenroll-token-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{t('crowdsecConfig.consoleEnrollment.agentName')}
</label>
<Input
type="text"
value={consoleAgentName}
onChange={(e) => setConsoleAgentName(e.target.value)}
placeholder={t('crowdsecConfig.reenroll.agentPlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{t('crowdsecConfig.reenroll.tenantOptional')}
</label>
<Input
type="text"
value={consoleTenant}
onChange={(e) => setConsoleTenant(e.target.value)}
placeholder={t('crowdsecConfig.reenroll.orgPlaceholder')}
/>
</div>
</div>
<div className="flex gap-3">
<Button
variant="primary"
onClick={() => submitConsoleEnrollment(true)}
disabled={!enrollmentToken.trim() || enrollConsoleMutation.isPending}
isLoading={enrollConsoleMutation.isPending}
data-testid="reenroll-submit-btn"
>
{enrollConsoleMutation.isPending ? t('crowdsecConfig.reenroll.reenrolling') : t('crowdsecConfig.reenroll.reenroll')}
</Button>
<Button
variant="ghost"
onClick={() => setShowReenrollForm(false)}
>
{t('common.cancel')}
</Button>
</div>
</div>
)}
{/* Clear enrollment option */}
<div className="pt-3 border-t border-gray-700">
<button
onClick={() => {
if (window.confirm(t('crowdsecConfig.reenroll.clearConfirm'))) {
clearEnrollmentMutation.mutate()
}
}}
className="text-sm text-gray-500 hover:text-gray-400"
disabled={clearEnrollmentMutation.isPending}
data-testid="clear-enrollment-btn"
>
{clearEnrollmentMutation.isPending ? t('crowdsecConfig.reenroll.clearing') : t('crowdsecConfig.reenroll.clearState')}
</button>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm text-gray-400">
<div>
<p className="text-xs text-gray-500">{t('crowdsecConfig.consoleEnrollment.agent')}</p>
<p className="text-white">{consoleStatusQuery.data?.agent_name || consoleAgentName || '—'}</p>
</div>
<div>
<p className="text-xs text-gray-500">{t('crowdsecConfig.consoleEnrollment.tenantLabel')}</p>
<p className="text-white">{consoleStatusQuery.data?.tenant || consoleTenant || '—'}</p>
</div>
<div>
<p className="text-xs text-gray-500">{t('crowdsecConfig.consoleEnrollment.enrollmentToken')}</p>
<p className="text-white" data-testid="console-token-state">{consoleTokenState}</p>
</div>
<div className="md:col-span-3 flex flex-wrap gap-4 text-xs text-gray-500">
<span>{t('crowdsecConfig.consoleEnrollment.lastAttempt')}: {consoleStatusQuery.data?.last_attempt_at ? new Date(consoleStatusQuery.data.last_attempt_at).toLocaleString() : '—'}</span>
<span>{t('crowdsecConfig.consoleEnrollment.enrolledAt')}: {consoleStatusQuery.data?.enrolled_at ? new Date(consoleStatusQuery.data.enrolled_at).toLocaleString() : '—'}</span>
{consoleStatusQuery.data?.correlation_id && <span>{t('crowdsecConfig.consoleEnrollment.correlationId')}: {consoleStatusQuery.data.correlation_id}</span>}
</div>
</div>
</div>
</Card>
)}
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h3 className="text-md font-semibold">{t('crowdsecConfig.packages.title')}</h3>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleExport}
disabled={importMutation.isPending || backupMutation.isPending}
>
{t('crowdsecConfig.packages.export')}
</Button>
<Button onClick={handleImport} disabled={!file || importMutation.isPending || backupMutation.isPending} data-testid="import-btn">
{importMutation.isPending ? t('crowdsecConfig.packages.importing') : t('crowdsecConfig.packages.import')}
</Button>
</div>
</div>
<p className="text-sm text-gray-400">{t('crowdsecConfig.packages.description')}</p>
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
</div>
</Card>
<Card>
<div className="space-y-4">
<div className="space-y-1">
<h3 className="text-md font-semibold">{t('crowdsecConfig.presets.title')}</h3>
<p className="text-sm text-gray-400">{t('crowdsecConfig.presets.description')}</p>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder={t('crowdsecConfig.presets.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
>
<option value="alpha">{t('crowdsecConfig.presets.sortAlpha')}</option>
<option value="source">{t('crowdsecConfig.presets.sortSource')}</option>
</select>
</div>
<div className="border border-gray-700 rounded-lg max-h-60 overflow-y-auto bg-gray-900">
{filteredPresets.length > 0 ? (
filteredPresets.map((preset) => (
<div
key={preset.slug}
onClick={() => setSelectedPresetSlug(preset.slug)}
className={`p-3 cursor-pointer hover:bg-gray-800 border-b border-gray-800 last:border-0 ${
selectedPresetSlug === preset.slug ? 'bg-blue-900/20 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent'
}`}
>
<div className="flex justify-between items-start mb-1">
<span className="font-medium text-white">{preset.title}</span>
{preset.source && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
preset.source === 'charon-curated' ? 'bg-purple-900/50 text-purple-300' : 'bg-blue-900/50 text-blue-300'
}`}>
{preset.source === 'charon-curated' ? t('crowdsecConfig.presets.curated') : t('crowdsecConfig.presets.hub')}
</span>
)}
</div>
<p className="text-xs text-gray-400 line-clamp-1">{preset.description}</p>
</div>
))
) : (
<div className="p-4 text-center text-gray-500 text-sm">{t('crowdsecConfig.presets.noResults', { query: searchQuery })}</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Button
variant="secondary"
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
disabled={!selectedPreset || pullPresetMutation.isPending}
isLoading={pullPresetMutation.isPending}
>
{t('crowdsecConfig.presets.pullPreview')}
</Button>
<Button
onClick={handleApplyPreset}
disabled={presetActionDisabled}
isLoading={isApplyingPreset}
data-testid="apply-preset-btn"
>
{t('crowdsecConfig.presets.applyPreset')}
</Button>
</div>
{validationError && (
<p className="text-sm text-red-400" data-testid="preset-validation-error">{validationError}</p>
)}
{presetStatusMessage && (
<p className="text-sm text-yellow-300" data-testid="preset-status">{presetStatusMessage}</p>
)}
{hubUnavailable && (
<div className="flex flex-wrap gap-2 items-center text-sm text-red-300" data-testid="preset-hub-unavailable">
<span>{t('crowdsecConfig.presets.hubUnreachable')}</span>
<Button
size="sm"
variant="secondary"
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
disabled={pullPresetMutation.isPending}
>
{t('crowdsecConfig.presets.retry')}
</Button>
{selectedPreset?.cached && (
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>{t('crowdsecConfig.presets.useCached')}</Button>
)}
</div>
)}
{selectedPreset && (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-white">{selectedPreset.title}</p>
<p className="text-sm text-gray-400">{selectedPreset.description}</p>
{selectedPreset.warning && (
<p className="text-xs text-yellow-300" data-testid="preset-warning">{selectedPreset.warning}</p>
)}
<p className="text-xs text-gray-500">{t('crowdsecConfig.presets.targetFile')}: {selectedPath ?? t('crowdsecConfig.presets.selectFileBelow')} </p>
</div>
{presetMeta && (
<div className="text-xs text-gray-400 flex flex-wrap gap-3" data-testid="preset-meta">
<span>{t('crowdsecConfig.presets.cacheKey')}: {presetMeta.cacheKey || '—'}</span>
<span>{t('crowdsecConfig.presets.etag')}: {presetMeta.etag || '—'}</span>
<span>{t('crowdsecConfig.presets.source')}: {presetMeta.source || selectedPreset.source || '—'}</span>
<span>{t('crowdsecConfig.presets.fetched')}: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'}</span>
</div>
)}
<div>
<p className="text-xs text-gray-400 mb-2">{t('crowdsecConfig.presets.previewYaml')}</p>
<pre
className="bg-gray-900 border border-gray-800 rounded-lg p-3 text-sm text-gray-200 whitespace-pre-wrap"
data-testid="preset-preview"
>
{presetPreview || selectedPreset.content || t('crowdsecConfig.presets.previewUnavailable')}
</pre>
</div>
{applyInfo && (
<div className="rounded-lg border border-gray-800 bg-gray-900/70 p-3 text-xs text-gray-200" data-testid="preset-apply-info">
<p>{t('common.status')}: {applyInfo.status || t('crowdsecConfig.presets.applied')}</p>
{applyInfo.backup && <p>{t('crowdsecConfig.presets.backup')}: {applyInfo.backup}</p>}
{applyInfo.reloadHint && <p>{t('crowdsecConfig.presets.reload')}: {t('crowdsecConfig.presets.required')}</p>}
{applyInfo.usedCscli !== undefined && <p>{t('crowdsecConfig.presets.method')}: {applyInfo.usedCscli ? 'cscli' : t('crowdsecConfig.presets.filesystem')}</p>}
</div>
)}
<div className="flex flex-wrap gap-2 items-center text-xs text-gray-400">
{selectedPreset.cached && (
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>
{t('crowdsecConfig.presets.loadCached')}
</Button>
)}
{selectedPresetRequiresHub && hubUnavailable && (
<span className="text-red-300">{t('crowdsecConfig.presets.applyDisabled')}</span>
)}
</div>
</div>
)}
{presetCatalog.length === 0 && (
<p className="text-sm text-gray-500">{t('crowdsecConfig.presets.noPresets')}</p>
)}
</div>
</Card>
<Card>
<div className="space-y-4">
<h3 className="text-md font-semibold">{t('crowdsecConfig.files.title')}</h3>
<div className="flex items-center gap-2">
<select
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
value={selectedPath ?? ''}
onChange={(e) => handleReadFile(e.target.value)}
data-testid="crowdsec-file-select"
>
<option value="">{t('crowdsecConfig.files.selectFile')}</option>
{listMutation.data?.files?.map((f) => (
<option value={f} key={f}>{f}</option>
))}
</select>
<Button variant="secondary" onClick={() => listMutation.refetch()}>{t('crowdsecConfig.files.refresh')}</Button>
</div>
<textarea value={fileContent ?? ''} onChange={(e) => setFileContent(e.target.value)} rows={12} className="w-full bg-gray-900 border border-gray-700 rounded-lg p-3 text-white" />
<div className="flex gap-2">
<Button onClick={handleSaveFile} isLoading={writeMutation.isPending || backupMutation.isPending}>{t('common.save')}</Button>
<Button variant="secondary" onClick={() => { setSelectedPath(null); setFileContent(null) }}>{t('common.close')}</Button>
</div>
</div>
</Card>
{/* Banned IPs Section */}
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-red-400" />
<h3 className="text-md font-semibold">{t('crowdsecConfig.bannedIps.title')}</h3>
</div>
<Button
onClick={() => setShowBanModal(true)}
disabled={status.crowdsec.mode === 'disabled'}
size="sm"
>
<ShieldOff className="h-4 w-4 mr-1" />
{t('crowdsecConfig.bannedIps.banIp')}
</Button>
</div>
{status.crowdsec.mode === 'disabled' ? (
<p className="text-sm text-gray-500">{t('crowdsecConfig.bannedIps.enableFirst')}</p>
) : decisionsQuery.isLoading ? (
<p className="text-sm text-gray-400">{t('crowdsecConfig.bannedIps.loading')}</p>
) : decisionsQuery.error ? (
<p className="text-sm text-red-400">{t('crowdsecConfig.bannedIps.loadFailed')}</p>
) : !decisionsQuery.data?.decisions?.length ? (
<p className="text-sm text-gray-500">{t('crowdsecConfig.bannedIps.none')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.ip')}</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.reason')}</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.duration')}</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.bannedAt')}</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.source')}</th>
<th className="text-right py-2 px-3 text-gray-400 font-medium">{t('crowdsecConfig.bannedIps.actions')}</th>
</tr>
</thead>
<tbody>
{decisionsQuery.data.decisions.map((decision) => (
<tr key={decision.id} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-2 px-3 font-mono text-white">{decision.ip}</td>
<td className="py-2 px-3 text-gray-300">{decision.reason || '-'}</td>
<td className="py-2 px-3 text-gray-300">{decision.duration}</td>
<td className="py-2 px-3 text-gray-300">
{decision.created_at ? new Date(decision.created_at).toLocaleString() : '-'}
</td>
<td className="py-2 px-3 text-gray-300">{decision.source || 'manual'}</td>
<td className="py-2 px-3 text-right">
<Button
variant="danger"
size="sm"
onClick={() => setConfirmUnban(decision)}
>
<Trash2 className="h-3 w-3 mr-1" />
{t('crowdsecConfig.bannedIps.unban')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
</div>
{/* Ban IP Modal */}
{showBanModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={() => setShowBanModal(false)} />
<div className="relative bg-dark-card rounded-lg p-6 w-[480px] max-w-full">
<h3 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<ShieldOff className="h-5 w-5 text-red-400" />
{t('crowdsecConfig.banModal.title')}
</h3>
<div className="space-y-4">
<Input
label={t('crowdsecConfig.banModal.ipLabel')}
placeholder="192.168.1.100"
value={banForm.ip}
onChange={(e) => setBanForm({ ...banForm, ip: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">{t('crowdsecConfig.banModal.durationLabel')}</label>
<select
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white"
value={banForm.duration}
onChange={(e) => setBanForm({ ...banForm, duration: e.target.value })}
>
<option value="1h">{t('crowdsecConfig.banModal.duration1h')}</option>
<option value="4h">{t('crowdsecConfig.banModal.duration4h')}</option>
<option value="24h">{t('crowdsecConfig.banModal.duration24h')}</option>
<option value="7d">{t('crowdsecConfig.banModal.duration7d')}</option>
<option value="30d">{t('crowdsecConfig.banModal.duration30d')}</option>
<option value="permanent">{t('crowdsecConfig.banModal.durationPermanent')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">{t('crowdsecConfig.banModal.reasonLabel')}</label>
<textarea
placeholder={t('crowdsecConfig.banModal.reasonPlaceholder')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
value={banForm.reason}
onChange={(e) => setBanForm({ ...banForm, reason: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3 justify-end mt-6">
<Button variant="secondary" onClick={() => setShowBanModal(false)}>
{t('common.cancel')}
</Button>
<Button
variant="danger"
onClick={() => banMutation.mutate()}
isLoading={banMutation.isPending}
disabled={!banForm.ip.trim()}
>
{t('crowdsecConfig.banModal.submit')}
</Button>
</div>
</div>
</div>
)}
{/* Unban Confirmation Modal */}
{confirmUnban && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={() => setConfirmUnban(null)} />
<div className="relative bg-dark-card rounded-lg p-6 w-[400px] max-w-full">
<h3 className="text-xl font-semibold text-white mb-4">{t('crowdsecConfig.unbanModal.title')}</h3>
<p className="text-gray-300 mb-6">
{t('crowdsecConfig.unbanModal.confirm')} <span className="font-mono text-white">{confirmUnban.ip}</span>?
</p>
<div className="flex gap-3 justify-end">
<Button variant="secondary" onClick={() => setConfirmUnban(null)}>
{t('common.cancel')}
</Button>
<Button
variant="danger"
onClick={() => unbanMutation.mutate(confirmUnban.ip)}
isLoading={unbanMutation.isPending}
>
{t('crowdsecConfig.unbanModal.submit')}
</Button>
</div>
</div>
</div>
)}
</>
)
}