Files
Charon/frontend/src/pages/CrowdSecConfig.tsx
Jeremy cf6d3bd319 fix: resolve modal dropdown z-index conflicts across application
Restructure 7 modal components to use 3-layer architecture preventing
native select dropdown menus from being blocked by modal overlays.

Components fixed:
- ProxyHostForm: ACL selector and Security Headers dropdowns
- User management: Role and permission mode selection
- Uptime monitors: Monitor type selection (HTTP/TCP)
- Remote servers: Provider selection dropdown
- CrowdSec: IP ban duration selection

The fix separates modal background overlay (z-40) from form container
(z-50) and enables pointer events only on form content, allowing
native dropdown menus to render above all modal layers.

Resolves user inability to select security policies, user roles,
monitor types, and other critical configuration options through
the UI interface.
2026-02-05 19:03:37 +00:00

1265 lines
58 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 { CrowdSecBouncerKeyDisplay } from '../components/CrowdSecBouncerKeyDisplay'
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)
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when slug changes, not on mutation/preset object identity changes
}, [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>
{/* CrowdSec Bouncer API Key - moved from Security Dashboard */}
{status.cerberus?.enabled && status.crowdsec.enabled && (
<CrowdSecBouncerKeyDisplay />
)}
<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 'alpha' | 'type' | 'source')}
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 && (
<>
{/* Layer 1: Background overlay (z-40) */}
<div className="fixed inset-0 bg-black/60 z-40" onClick={() => setShowBanModal(false)} />
{/* Layer 2: Form container (z-50, pointer-events-none) */}
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50">
{/* Layer 3: Form content (pointer-events-auto) */}
<div className="bg-dark-card rounded-lg p-6 w-[480px] max-w-full pointer-events-auto">
<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>
)}
</>
)
}