import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState, useEffect, useMemo } from 'react' import { useNavigate, Outlet } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink, Settings } from 'lucide-react' import { getSecurityStatus, type SecurityStatus } from '../api/security' import { useSecurityConfig, useUpdateSecurityConfig, useGenerateBreakGlassToken } from '../hooks/useSecurity' import { startCrowdsec, stopCrowdsec, statusCrowdsec } from '../api/crowdsec' import { updateSetting } from '../api/settings' import { toast } from '../utils/toast' import { ConfigReloadOverlay } from '../components/LoadingStates' import { LiveLogViewer } from '../components/LiveLogViewer' import { SecurityNotificationSettingsModal } from '../components/SecurityNotificationSettingsModal' import { CrowdSecKeyWarning } from '../components/CrowdSecKeyWarning' import { PageShell } from '../components/layout/PageShell' import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button, Badge, Alert, Switch, Skeleton, Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, } from '../components/ui' // Skeleton loader for security layer cards function SecurityCardSkeleton() { return (
) } // Loading skeleton for the entire security page function SecurityPageSkeleton({ t }: { t: (key: string) => string }) { return (
) } export default function Security() { const { t } = useTranslation() const navigate = useNavigate() const { data: status, isLoading } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus, }) const { data: securityConfig } = useSecurityConfig() const [adminWhitelist, setAdminWhitelist] = useState('') const [showNotificationSettings, setShowNotificationSettings] = useState(false) useEffect(() => { if (securityConfig && securityConfig.config) { setAdminWhitelist(securityConfig.config.admin_whitelist || '') } }, [securityConfig]) const updateSecurityConfigMutation = useUpdateSecurityConfig() const generateBreakGlassMutation = useGenerateBreakGlassToken() const queryClient = useQueryClient() const [crowdsecStatus, setCrowdsecStatus] = useState<{ running: boolean; pid?: number } | null>(null) // Stable reference to prevent WebSocket reconnection loops in LiveLogViewer const emptySecurityFilters = useMemo(() => ({}), []) // Generic toggle mutation for per-service settings const toggleServiceMutation = useMutation({ mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => { await updateSetting(key, enabled ? 'true' : 'false', 'security', 'bool') }, onMutate: async ({ key, enabled }: { key: string; enabled: boolean }) => { // Cancel ongoing queries to avoid race conditions await queryClient.cancelQueries({ queryKey: ['security-status'] }) // Snapshot current state for rollback const previous = queryClient.getQueryData(['security-status']) // Optimistic update: parse key like "security.acl.enabled" -> section "acl" queryClient.setQueryData(['security-status'], (old: unknown) => { if (!old || typeof old !== 'object') return old const oldStatus = old as SecurityStatus const copy = { ...oldStatus } // Extract section from key (e.g., "security.acl.enabled" -> "acl") const parts = key.split('.') const section = parts[1] // CRITICAL: Spread existing section data to preserve fields like 'mode' // Update ONLY the enabled field, keep everything else intact if (section === 'acl') { copy.acl = { ...copy.acl, enabled } } else if (section === 'waf') { // Preserve mode field (detection/prevention) copy.waf = { ...copy.waf, enabled } } else if (section === 'rate_limit') { // Preserve mode field (log/block) copy.rate_limit = { ...copy.rate_limit, enabled } } return copy }) return { previous } }, onError: (_err, _vars, context: unknown) => { // Rollback on error if (context && typeof context === 'object' && 'previous' in context) { queryClient.setQueryData(['security-status'], context.previous) } const msg = _err instanceof Error ? _err.message : String(_err) toast.error(`Failed to update setting: ${msg}`) }, onSuccess: () => { // Refresh data from server queryClient.invalidateQueries({ queryKey: ['settings'] }) queryClient.invalidateQueries({ queryKey: ['security-status'] }) toast.success('Security setting updated') }, }) const fetchCrowdsecStatus = async () => { try { const s = await statusCrowdsec() setCrowdsecStatus(s) } catch { setCrowdsecStatus(null) } } useEffect(() => { fetchCrowdsecStatus() }, []) const crowdsecPowerMutation = useMutation({ mutationFn: async (enabled: boolean) => { // Update setting first await updateSetting('security.crowdsec.enabled', enabled ? 'true' : 'false', 'security', 'bool') if (enabled) { toast.info('Starting CrowdSec... This may take up to 30 seconds') const result = await startCrowdsec() // VERIFY: Check if it actually started const status = await statusCrowdsec() if (!status.running) { // Revert the setting since process didn't start await updateSetting('security.crowdsec.enabled', 'false', 'security', 'bool') throw new Error('CrowdSec process failed to start. Check server logs for details.') } return result } else { await stopCrowdsec() // VERIFY: Check if it actually stopped (with brief delay for cleanup) await new Promise(resolve => setTimeout(resolve, 500)) const status = await statusCrowdsec() if (status.running) { throw new Error('CrowdSec process still running. Check server logs for details.') } return { enabled: false } } }, // NO optimistic updates - wait for actual confirmation onError: (err: unknown, enabled: boolean) => { const msg = err instanceof Error ? err.message : String(err) toast.error(enabled ? `Failed to start CrowdSec: ${msg}` : `Failed to stop CrowdSec: ${msg}`) // Force refresh status from backend to ensure UI matches reality queryClient.invalidateQueries({ queryKey: ['security-status'] }) fetchCrowdsecStatus() }, onSuccess: async (result: { lapi_ready?: boolean; enabled?: boolean } | boolean) => { // Refresh all related queries to ensure consistency await Promise.all([ queryClient.invalidateQueries({ queryKey: ['security-status'] }), queryClient.invalidateQueries({ queryKey: ['settings'] }), fetchCrowdsecStatus(), ]) if (typeof result === 'object' && result.lapi_ready === true) { toast.success('CrowdSec started and LAPI is ready') } else if (typeof result === 'object' && result.lapi_ready === false) { toast.warning('CrowdSec started but LAPI is still initializing. Please wait before enrolling.') } else if (typeof result === 'object' && result.enabled === false) { toast.success('CrowdSec stopped') } else { toast.success('CrowdSec started') } }, }) // Determine if any security operation is in progress const isApplyingConfig = toggleServiceMutation.isPending || updateSecurityConfigMutation.isPending || generateBreakGlassMutation.isPending || crowdsecPowerMutation.isPending // Determine contextual message const getMessage = () => { if (toggleServiceMutation.isPending) { return { message: t('security.threeHeadsTurn'), submessage: t('security.cerberusConfigUpdating') } } if (crowdsecPowerMutation.isPending) { return crowdsecPowerMutation.variables ? { message: t('security.summoningGuardian'), submessage: t('security.crowdsecStarting') } : { message: t('security.guardianRests'), submessage: t('security.crowdsecStopping') } } return { message: t('security.strengtheningGuard'), submessage: t('security.wardsActivating') } } const { message, submessage } = getMessage() if (isLoading) { return } if (!status) { return ( {t('security.failedToLoadConfiguration')} ) } const cerberusDisabled = !status.cerberus?.enabled const crowdsecToggleDisabled = cerberusDisabled || crowdsecPowerMutation.isPending const crowdsecControlsDisabled = cerberusDisabled || crowdsecPowerMutation.isPending // Header actions const headerActions = (
) return ( {isApplyingConfig && ( )} {/* Cerberus Status Header */}

{t('security.cerberusDashboard')}

{status.cerberus?.enabled ? t('security.cerberusActive') : t('security.cerberusDisabled')}

{status.cerberus?.enabled ? t('security.cerberusReadyMessage') : t('security.cerberusDisabledMessage')}

{/* Cerberus Disabled Alert */} {!status.cerberus?.enabled && (

{t('security.featuresUnavailableMessage')}

)} {/* CrowdSec Key Rejection Warning */} {status.cerberus?.enabled && (crowdsecStatus?.running ?? status.crowdsec.enabled) && ( )} {/* Admin Whitelist Section */} {status.cerberus?.enabled && ( {t('security.adminWhitelist')} {t('security.adminWhitelistDescription')}
setAdminWhitelist(e.target.value)} placeholder="192.168.1.0/24, 10.0.0.1" />

{t('security.generateTokenTooltip')}

)} {/* Security Layer Cards */}
{/* CrowdSec - Layer 1: IP Reputation */}
{t('security.layer1')} {t('security.ids')}
{(crowdsecStatus?.running ?? status.crowdsec.enabled) ? t('common.enabled') : t('common.disabled')}
{t('security.crowdsec')} {t('security.crowdsecDescription')}

{(crowdsecStatus?.running ?? status.crowdsec.enabled) ? t('security.crowdsecProtects') : t('security.crowdsecDisabledDescription')}

{crowdsecStatus && (

{crowdsecStatus.running ? t('security.runningPid', { pid: crowdsecStatus.pid }) : t('security.processStopped')}

)}
crowdsecPowerMutation.mutate(checked)} data-testid="toggle-crowdsec" />

{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleCrowdsec')}

{/* ACL - Layer 2: Access Control */}
{t('security.layer2')} {t('security.acl')}
{status.acl.enabled ? t('common.enabled') : t('common.disabled')}
{t('security.accessControl')} {t('security.aclDescription')}

{t('security.aclProtects')}

toggleServiceMutation.mutate({ key: 'security.acl.enabled', enabled: checked })} data-testid="toggle-acl" />

{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleAcl')}

{/* Coraza - Layer 3: Request Inspection */}
{t('security.layer3')} {t('security.waf')}
{status.waf.enabled ? t('common.enabled') : t('common.disabled')}
{t('security.corazaWaf')} {t('security.wafDescription')}

{status.waf.enabled ? t('security.wafProtects') : t('security.wafDisabledDescription')}

toggleServiceMutation.mutate({ key: 'security.waf.enabled', enabled: checked })} data-testid="toggle-waf" />

{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleWaf')}

{/* Rate Limiting - Layer 4: Volume Control */}
{t('security.layer4')} {t('security.rate')}
{status.rate_limit.enabled ? t('common.enabled') : t('common.disabled')}
{t('security.rateLimiting')} {t('security.rateLimitDescription')}

{t('security.rateLimitProtects')}

toggleServiceMutation.mutate({ key: 'security.rate_limit.enabled', enabled: checked })} data-testid="toggle-rate-limit" />

{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleRateLimit')}

{/* Live Activity Section */} {status.cerberus?.enabled && ( )} {/* Notification Settings Modal */} setShowNotificationSettings(false)} />
) }