Files
Charon/frontend/src/components/ProxyHostForm.tsx
Jeremy dd96493edb 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-04 19:23:35 +00:00

1375 lines
60 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react'
import { CircleHelp, AlertCircle, Check, X, Loader2, Copy, Info, AlertTriangle } from 'lucide-react'
import { toast } from 'react-hot-toast'
import type { ProxyHost, ApplicationPreset } from '../api/proxyHosts'
import { testProxyHostConnection } from '../api/proxyHosts'
import { syncMonitors } from '../api/uptime'
import { useRemoteServers } from '../hooks/useRemoteServers'
import { useDomains } from '../hooks/useDomains'
import { useCertificates } from '../hooks/useCertificates'
import { useDocker } from '../hooks/useDocker'
import AccessListSelector from './AccessListSelector'
import { useSecurityHeaderProfiles } from '../hooks/useSecurityHeaders'
import { SecurityScoreDisplay } from './SecurityScoreDisplay'
import { parse } from 'tldts'
import { Alert } from './ui/Alert'
import { isLikelyDockerContainerIP, isPrivateOrDockerIP } from '../utils/validation'
import DNSProviderSelector from './DNSProviderSelector'
import { useDetectDNSProvider } from '../hooks/useDNSDetection'
import { DNSDetectionResult } from './DNSDetectionResult'
import type { DNSProvider } from '../api/dnsProviders'
// Application preset configurations
const APPLICATION_PRESETS: { value: ApplicationPreset; label: string; description: string }[] = [
{ value: 'none', label: 'None', description: 'Standard reverse proxy' },
{ value: 'plex', label: 'Plex', description: 'Media server with remote access' },
{ value: 'jellyfin', label: 'Jellyfin', description: 'Open source media server' },
{ value: 'emby', label: 'Emby', description: 'Media server' },
{ value: 'homeassistant', label: 'Home Assistant', description: 'Home automation' },
{ value: 'nextcloud', label: 'Nextcloud', description: 'File sync and share' },
{ value: 'vaultwarden', label: 'Vaultwarden', description: 'Password manager' },
]
// Docker image to preset mapping for auto-detection
const IMAGE_TO_PRESET: Record<string, ApplicationPreset> = {
'plexinc/pms-docker': 'plex',
'linuxserver/plex': 'plex',
'jellyfin/jellyfin': 'jellyfin',
'linuxserver/jellyfin': 'jellyfin',
'emby/embyserver': 'emby',
'linuxserver/emby': 'emby',
'homeassistant/home-assistant': 'homeassistant',
'ghcr.io/home-assistant/home-assistant': 'homeassistant',
'nextcloud': 'nextcloud',
'linuxserver/nextcloud': 'nextcloud',
'vaultwarden/server': 'vaultwarden',
}
// Advanced Caddy config snippets for presets (auto-populate for review)
const PRESET_ADVANCED_CONFIG: Record<ApplicationPreset, string> = {
none: '',
plex: JSON.stringify([
{
handler: 'headers',
request: {
set: {
'X-Plex-Client-Identifier': '{http.request.header.X-Plex-Client-Identifier}',
'X-Real-IP': '{http.request.remote.host}',
'X-Forwarded-Host': '{http.request.host}',
},
},
},
], null, 2),
jellyfin: JSON.stringify([
{
handler: 'headers',
request: {
set: {
'X-Real-IP': '{http.request.remote.host}',
'X-Forwarded-Host': '{http.request.host}',
},
},
},
], null, 2),
emby: JSON.stringify([
{
handler: 'headers',
request: { set: { 'X-Real-IP': '{http.request.remote.host}' } },
},
], null, 2),
homeassistant: JSON.stringify([
{ handler: 'headers', request: { set: { 'X-Real-IP': '{http.request.remote.host}' } } },
], null, 2),
nextcloud: JSON.stringify([
{ handler: 'headers', request: { set: { 'X-Real-IP': '{http.request.remote.host}', 'X-Forwarded-Host': '{http.request.host}' } } },
], null, 2),
vaultwarden: JSON.stringify([
{ handler: 'headers', request: { set: { 'X-Real-IP': '{http.request.remote.host}' } } },
], null, 2),
}
interface ProxyHostFormProps {
host?: ProxyHost
onSubmit: (data: Partial<ProxyHost>) => Promise<void>
onCancel: () => void
}
export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
type ProxyHostFormState = Partial<ProxyHost> & { addUptime?: boolean; uptimeInterval?: number; uptimeMaxRetries?: number }
const [formData, setFormData] = useState<ProxyHostFormState>({
name: host?.name || '',
domain_names: host?.domain_names || '',
forward_scheme: host?.forward_scheme || 'http',
forward_host: host?.forward_host || '',
forward_port: host?.forward_port || 80,
ssl_forced: host?.ssl_forced ?? true,
http2_support: host?.http2_support ?? true,
hsts_enabled: host?.hsts_enabled ?? true,
hsts_subdomains: host?.hsts_subdomains ?? true,
block_exploits: host?.block_exploits ?? true,
websocket_support: host?.websocket_support ?? true,
enable_standard_headers: host?.enable_standard_headers ?? true,
application: (host?.application || 'none') as ApplicationPreset,
advanced_config: host?.advanced_config || '',
enabled: host?.enabled ?? true,
certificate_id: host?.certificate_id,
access_list_id: host?.access_list_id,
security_header_profile_id: host?.security_header_profile_id,
dns_provider_id: host?.dns_provider_id || null,
})
// Charon internal IP for config helpers (previously CPMP internal IP)
const [charonInternalIP, setCharonInternalIP] = useState<string>('')
const [copiedField, setCopiedField] = useState<string | null>(null)
// DNS auto-detection state
const { mutateAsync: detectProvider, isPending: isDetecting, data: detectionResult, reset: resetDetection } = useDetectDNSProvider()
const [manualProviderSelection, setManualProviderSelection] = useState(false)
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Fetch Charon internal IP on mount (legacy: CPMP internal IP)
useEffect(() => {
fetch('/api/v1/health')
.then(res => res.json())
.then(data => {
if (data.internal_ip) {
setCharonInternalIP(data.internal_ip)
}
})
.catch(() => {})
}, [])
// Auto-detect DNS provider when wildcard domain is entered (debounced 500ms)
useEffect(() => {
// Clear any pending detection
if (detectionTimeoutRef.current) {
clearTimeout(detectionTimeoutRef.current)
detectionTimeoutRef.current = null
}
// Reset detection if domain is cleared or manual selection is active
if (!formData.domain_names || manualProviderSelection) {
resetDetection()
return
}
// Check if domain contains wildcard
const domains = formData.domain_names.split(',').map(d => d.trim())
const wildcardDomain = domains.find(d => d.startsWith('*'))
if (!wildcardDomain) {
resetDetection()
return
}
// Extract base domain from wildcard (*.example.com -> example.com)
const baseDomain = wildcardDomain.replace(/^\*\./, '')
// Don't detect if provider already set (unless detection succeeded before)
if (formData.dns_provider_id && !detectionResult?.suggested_provider) {
return
}
// Debounce detection call by 500ms
detectionTimeoutRef.current = setTimeout(() => {
detectProvider(baseDomain).catch(err => {
console.error('DNS detection failed:', err)
})
}, 500)
return () => {
if (detectionTimeoutRef.current) {
clearTimeout(detectionTimeoutRef.current)
}
}
}, [formData.domain_names, formData.dns_provider_id, detectProvider, resetDetection, detectionResult, manualProviderSelection])
// Auto-select suggested provider if confidence is high
useEffect(() => {
if (detectionResult?.suggested_provider && detectionResult.confidence === 'high' && !manualProviderSelection && !formData.dns_provider_id) {
setFormData(prev => ({ ...prev, dns_provider_id: detectionResult.suggested_provider!.id }))
toast.success(`Auto-selected: ${detectionResult.suggested_provider.name}`)
}
}, [detectionResult, manualProviderSelection, formData.dns_provider_id])
// Handle using suggested provider
const handleUseSuggested = useCallback((provider: DNSProvider) => {
setFormData(prev => ({ ...prev, dns_provider_id: provider.id }))
setManualProviderSelection(false)
toast.success(`Selected: ${provider.name}`)
}, [])
// Handle manual provider selection
const handleManualSelection = useCallback(() => {
setManualProviderSelection(true)
}, [])
// Auto-detect application preset from Docker image
const detectApplicationPreset = (imageName: string): ApplicationPreset => {
const lowerImage = imageName.toLowerCase()
for (const [pattern, preset] of Object.entries(IMAGE_TO_PRESET)) {
if (lowerImage.includes(pattern.toLowerCase())) {
return preset
}
}
return 'none'
}
// Copy to clipboard helper
const copyToClipboard = async (text: string, field: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedField(field)
setTimeout(() => setCopiedField(null), 2000)
} catch {
// Silently fail if clipboard access is denied
}
}
// Get the external URL for this proxy host
const getExternalUrl = () => {
const domain = (formData.domain_names ?? '').split(',')[0]?.trim()
if (!domain) return ''
return `https://${domain}:443`
}
const { servers: remoteServers } = useRemoteServers()
const { domains, createDomain } = useDomains()
const { certificates } = useCertificates()
const { data: securityProfiles } = useSecurityHeaderProfiles()
const [connectionSource, setConnectionSource] = useState<'local' | 'custom' | string>('custom')
const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker(
connectionSource === 'local' ? 'local' : undefined,
connectionSource !== 'local' && connectionSource !== 'custom' ? connectionSource : undefined
)
const [selectedDomain, setSelectedDomain] = useState('')
const [selectedContainerId, setSelectedContainerId] = useState<string>('')
// New Domain Popup State
const [showDomainPrompt, setShowDomainPrompt] = useState(false)
const [pendingDomain, setPendingDomain] = useState('')
const [dontAskAgain, setDontAskAgain] = useState(false)
useEffect(() => {
const stored = localStorage.getItem('charon_dont_ask_domain') ?? localStorage.getItem('cpmp_dont_ask_domain')
if (stored === 'true') {
setDontAskAgain(true)
}
}, [])
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
const [showPresetConfirmModal, setShowPresetConfirmModal] = useState(false)
const [pendingPreset, setPendingPreset] = useState<ApplicationPreset | null>(null)
const handleConfirmPresetOverwrite = () => {
if (!pendingPreset) return
const presetConfig = PRESET_ADVANCED_CONFIG[pendingPreset]
const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(pendingPreset)
setFormData(prev => ({
...prev,
application: pendingPreset,
websocket_support: needsWebsockets || prev.websocket_support,
advanced_config: presetConfig,
}))
setShowPresetConfirmModal(false)
setPendingPreset(null)
}
const handleCancelPresetOverwrite = () => {
setShowPresetConfirmModal(false)
setPendingPreset(null)
}
const handleRestoreBackup = () => {
if (host?.advanced_config_backup) {
setFormData(prev => ({ ...prev, advanced_config: host.advanced_config_backup || '' }))
}
}
const checkNewDomains = (input: string) => {
if (dontAskAgain) return
const domainList = input.split(',').map(d => d.trim()).filter(d => d)
for (const domain of domainList) {
const parsed = parse(domain)
if (parsed.domain && parsed.domain !== domain) {
// It's a subdomain, check if the base domain exists
const baseDomain = parsed.domain
const exists = domains.some(d => d.name === baseDomain)
if (!exists) {
setPendingDomain(baseDomain)
setShowDomainPrompt(true)
return // Only prompt for one at a time
}
} else if (parsed.domain && parsed.domain === domain) {
// It is a base domain, check if it exists
const exists = domains.some(d => d.name === domain)
if (!exists) {
setPendingDomain(domain)
setShowDomainPrompt(true)
return
}
}
}
}
const handleSaveDomain = async () => {
try {
await createDomain(pendingDomain)
setShowDomainPrompt(false)
} catch {
// Failed to save domain - user can retry
}
}
const handleDontAskToggle = (checked: boolean) => {
setDontAskAgain(checked)
localStorage.setItem('charon_dont_ask_domain', String(checked))
// Keep legacy value for stored user preference compatibility
localStorage.setItem('cpmp_dont_ask_domain', String(checked))
}
const handleTestConnection = async () => {
if (!formData.forward_host || !formData.forward_port) return
setTestStatus('testing')
try {
await testProxyHostConnection(formData.forward_host, formData.forward_port)
setTestStatus('success')
// Reset status after 3 seconds
setTimeout(() => setTestStatus('idle'), 3000)
} catch {
setTestStatus('error')
// Reset status after 3 seconds
setTimeout(() => setTestStatus('idle'), 3000)
}
}
// Validate forward_host for IP address warnings
useEffect(() => {
const host = formData.forward_host?.trim() || ''
if (isLikelyDockerContainerIP(host)) {
setForwardHostWarning(
'This looks like a Docker container IP address. Docker IPs can change when containers restart. ' +
'Consider using the container name (e.g., "my-app" or "my-app:8080") for more reliable connections.'
)
} else if (isPrivateOrDockerIP(host)) {
setForwardHostWarning(
'Using a private IP address. If this is a Docker container, the IP may change on restart. ' +
'Container names are more reliable for Docker services.'
)
} else {
setForwardHostWarning(null)
}
}, [formData.forward_host])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [nameError, setNameError] = useState<string | null>(null)
const [forwardHostWarning, setForwardHostWarning] = useState<string | null>(null)
const [addUptime, setAddUptime] = useState(false)
const [uptimeInterval, setUptimeInterval] = useState(60)
const [uptimeMaxRetries, setUptimeMaxRetries] = useState(3)
// Wildcard domain detection for DNS-01 challenge requirement
const hasWildcardDomain = formData.domain_names
?.split(',')
.some(d => d.trim().startsWith('*'))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validate DNS provider for wildcard domains
if (hasWildcardDomain && !formData.dns_provider_id) {
toast.error('DNS provider is required for wildcard domains')
return
}
setLoading(true)
try {
const payload = { ...formData }
// strip temporary uptime-only flags from payload by destructuring
const { addUptime: _addUptime, uptimeInterval: _uptimeInterval, uptimeMaxRetries: _uptimeMaxRetries, ...payloadWithoutUptime } = payload as ProxyHostFormState
void _addUptime; void _uptimeInterval; void _uptimeMaxRetries;
const res = await onSubmit(payloadWithoutUptime)
// if user asked to add uptime, request server to sync monitors
if (addUptime) {
try {
await syncMonitors({ interval: uptimeInterval, max_retries: uptimeMaxRetries })
toast.success('Requested uptime monitor creation')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
toast.error(msg || 'Failed to request uptime creation')
}
}
onCancel()
return res
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save host'
setError(message)
toast.error(message)
throw err
} finally {
setLoading(false)
}
}
const tryApplyPreset = (preset: ApplicationPreset) => {
const presetConfig = PRESET_ADVANCED_CONFIG[preset] || ''
// Auto-apply if no advanced config is present
if (!formData.advanced_config || formData.advanced_config.trim() === '') {
const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(preset)
setFormData(prev => ({ ...prev, application: preset, websocket_support: needsWebsockets || prev.websocket_support, advanced_config: presetConfig }))
return
}
// Otherwise, prompt if different
if (formData.advanced_config.trim() !== presetConfig.trim()) {
setPendingPreset(preset)
setShowPresetConfirmModal(true)
return
}
}
const handleContainerSelect = (containerId: string) => {
setSelectedContainerId(containerId)
const container = dockerContainers.find(c => c.id === containerId)
if (container) {
// Prefer container name over IP for stability across restarts
// Container names work when both Charon and the target are on the same Docker network
let host = container.names[0] || container.ip
let port = container.ports && container.ports.length > 0 ? container.ports[0].private_port : 80
// If using local Docker and we have a container name, show info toast
if (connectionSource === 'local' && container.names[0]) {
toast.success(`Using container name "${container.names[0]}" for stable addressing`, { duration: 3000 })
}
// If using a Remote Server, try to use the Host IP and Mapped Public Port
if (connectionSource !== 'local' && connectionSource !== 'custom') {
const server = remoteServers.find(s => s.uuid === connectionSource)
if (server) {
// Use the Remote Server's Host IP (e.g. public/tailscale IP)
host = server.host
// Find a mapped public port
// We prefer the first mapped port we find
const mappedPort = container.ports?.find(p => p.public_port)
if (mappedPort) {
port = mappedPort.public_port
} else {
// If no public port is mapped, we can't reach it from outside
// But we'll leave the internal port as a fallback, though it likely won't work
}
}
}
let newDomainNames = formData.domain_names
if (selectedDomain) {
const subdomain = container.names[0].replace(/^\//, '')
newDomainNames = `${subdomain}.${selectedDomain}`
}
// Auto-detect application preset from image name
const detectedPreset = detectApplicationPreset(container.image)
// Auto-enable websockets for apps that need it
const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(detectedPreset)
// Try to apply the preset logic (auto-populate or prompt)
tryApplyPreset(detectedPreset)
setFormData({
...formData,
forward_host: host,
forward_port: port,
forward_scheme: 'http',
domain_names: newDomainNames,
application: detectedPreset,
websocket_support: needsWebsockets || formData.websocket_support,
})
}
}
const handleBaseDomainChange = (domain: string) => {
setSelectedDomain(domain)
if (selectedContainerId && domain) {
const container = dockerContainers.find(c => c.id === selectedContainerId)
if (container) {
const subdomain = container.names[0].replace(/^\//, '')
setFormData(prev => ({
...prev,
domain_names: `${subdomain}.${domain}`
}))
}
}
}
return (
<>
{/* Layer 1: Background overlay (z-40) */}
<div className="fixed inset-0 bg-black/50 z-40" onClick={onCancel} />
{/* Layer 2: Form container (z-50, pointer-events-none) */}
<div className="fixed inset-0 flex items-center justify-center p-4 pointer-events-none z-50">
{/* Layer 3: Form content (pointer-events-auto) */}
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-2xl w-full max-h-[90vh] overflow-y-auto pointer-events-auto">
<div className="p-6 border-b border-gray-800">
<h2 className="text-2xl font-bold text-white">
{host ? 'Edit Proxy Host' : 'Add Proxy Host'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
{/* Name Field */}
<div>
<label htmlFor="proxy-name" className="block text-sm font-medium text-gray-300 mb-2">
Name <span className="text-red-400">*</span>
</label>
<input
id="proxy-name"
type="text"
required
value={formData.name}
onChange={e => {
setFormData({ ...formData, name: e.target.value })
if (nameError && e.target.value.trim()) {
setNameError(null)
}
}}
placeholder="My Service"
className={`w-full bg-gray-900 border rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${
nameError ? 'border-red-500' : 'border-gray-700'
}`}
/>
{nameError ? (
<p className="text-xs text-red-400 mt-1">{nameError}</p>
) : (
<p className="text-xs text-gray-500 mt-1">
A friendly name to identify this proxy host
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Docker Container Quick Select */}
<div>
<label htmlFor="connection-source" className="block text-sm font-medium text-gray-300 mb-2">
Source
</label>
<select
id="connection-source"
value={connectionSource}
onChange={e => setConnectionSource(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="custom">Custom / Manual</option>
<option value="local">Local (Docker Socket)</option>
{remoteServers
.filter(s => s.provider === 'docker' && s.enabled)
.map(server => (
<option key={server.uuid} value={server.uuid}>
{server.name} ({server.host})
</option>
))
}
</select>
</div>
<div>
<label htmlFor="quick-select-docker" className="block text-sm font-medium text-gray-300 mb-2">
Containers
</label>
<select
id="quick-select-docker"
onChange={e => handleContainerSelect(e.target.value)}
disabled={dockerLoading || connectionSource === 'custom'}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value="">
{connectionSource === 'custom'
? 'Select a source to view containers'
: (dockerLoading ? 'Loading containers...' : '-- Select a container --')}
</option>
{dockerContainers.map(container => (
<option key={container.id} value={container.id}>
{container.names[0]} ({container.image})
</option>
))}
</select>
{dockerError && connectionSource !== 'custom' && (
<div className="mt-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-red-300">
<p className="font-semibold mb-1">Docker Connection Failed</p>
<p className="text-red-400/90 mb-2">
{(dockerError as Error).message}
</p>
<p className="text-gray-400">
<strong>Troubleshooting:</strong> Ensure Docker is running and the socket is accessible.
If running in a container, mount <code className="text-xs bg-gray-800 px-1 py-0.5 rounded">/var/run/docker.sock</code>.
</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Domain Names */}
<div className="space-y-4">
{domains.length > 0 && (
<div>
<label htmlFor="base-domain" className="block text-sm font-medium text-gray-300 mb-2">
Base Domain (Auto-fill)
</label>
<select
id="base-domain"
value={selectedDomain}
onChange={e => handleBaseDomainChange(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">-- Select a base domain --</option>
{domains.map(domain => (
<option key={domain.uuid} value={domain.name}>
{domain.name}
</option>
))}
</select>
</div>
)}
<div>
<label htmlFor="domain-names" className="block text-sm font-medium text-gray-300 mb-2">
Domain Names (comma-separated)
</label>
<input
id="domain-names"
type="text"
required
value={formData.domain_names}
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
onBlur={e => checkNewDomains(e.target.value)}
placeholder="example.com, www.example.com"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Forward Details */}
<div className="grid grid-cols-3 gap-4">
<div>
<label htmlFor="forward-scheme" className="block text-sm font-medium text-gray-300 mb-2">Scheme</label>
<select
id="forward-scheme"
value={formData.forward_scheme}
onChange={e => setFormData({ ...formData, forward_scheme: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div>
<label htmlFor="forward-host" className="block text-sm font-medium text-gray-300 mb-2">
Host
<div
title="Enter a hostname, container name, or IP address. For Docker containers, using the container name (e.g., 'my-nginx') is recommended as it remains stable across container restarts."
className="inline-block ml-1 text-gray-500 hover:text-gray-300 cursor-help"
>
<CircleHelp size={14} />
</div>
</label>
<input
id="forward-host"
type="text"
required
value={formData.forward_host}
onChange={e => setFormData({ ...formData, forward_host: e.target.value })}
placeholder="my-container or 192.168.1.100"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{forwardHostWarning && (
<Alert variant="warning" className="mt-2" title="IP Address Detected">
{forwardHostWarning}
</Alert>
)}
</div>
<div>
<label htmlFor="forward-port" className="block text-sm font-medium text-gray-300 mb-2">Port</label>
<input
id="forward-port"
type="number"
required
min="1"
max="65535"
value={formData.forward_port}
onChange={e => {
const v = parseInt(e.target.value)
setFormData({ ...formData, forward_port: Number.isNaN(v) ? 0 : v })
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* SSL Certificate Selection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
SSL Certificate
</label>
<select
value={formData.certificate_id || 0}
onChange={e => setFormData({ ...formData, certificate_id: parseInt(e.target.value) || null })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>Auto-manage with Let's Encrypt (recommended)</option>
{certificates.map(cert => (
<option key={cert.id || cert.domain} value={cert.id ?? 0}>
{(cert.name || cert.domain)}
{cert.provider ? ` (${cert.provider})` : ''}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Choose an existing certificate if already issued for these domains, or let Charon request/renew via Let's Encrypt automatically.
</p>
</div>
{/* DNS Provider Selector for Wildcard Domains */}
{hasWildcardDomain && (
<div className="space-y-3">
<Alert variant="info">
<Info className="h-4 w-4" />
<div>
<p className="font-medium">Wildcard Certificate Required</p>
<p className="text-sm mt-1">
Wildcard certificates (*.example.com) require DNS-01 challenge.
Select a DNS provider to automatically manage DNS records for certificate validation.
</p>
</div>
</Alert>
{/* DNS Detection Result */}
{(isDetecting || detectionResult) && !manualProviderSelection && (
<DNSDetectionResult
result={detectionResult!}
isLoading={isDetecting}
onUseSuggested={handleUseSuggested}
onSelectManually={handleManualSelection}
/>
)}
<DNSProviderSelector
value={formData.dns_provider_id ?? undefined}
onChange={(id) => {
setFormData(prev => ({ ...prev, dns_provider_id: id ?? null }))
if (id) {
setManualProviderSelection(true)
}
}}
required={true}
/>
</div>
)}
{/* Access Control List */}
<AccessListSelector
value={formData.access_list_id || null}
onChange={id => setFormData({ ...formData, access_list_id: id })}
/>
{/* Security Headers Profile */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Security Headers
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
</label>
<select
value={formData.security_header_profile_id || 0}
onChange={e => {
const value = e.target.value === "0" ? null : parseInt(e.target.value) || null
setFormData({ ...formData, security_header_profile_id: value })
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>None (No Security Headers)</option>
<optgroup label="Quick Presets">
{securityProfiles
?.filter(p => p.is_preset)
.sort((a, b) => a.security_score - b.security_score)
.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name} (Score: {profile.security_score}/100)
</option>
))}
</optgroup>
{(securityProfiles?.filter(p => !p.is_preset) || []).length > 0 && (
<optgroup label="Custom Profiles">
{(securityProfiles || [])
.filter(p => !p.is_preset)
.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name} (Score: {profile.security_score}/100)
</option>
))}
</optgroup>
)}
</select>
{formData.security_header_profile_id && (() => {
const selected = securityProfiles?.find(p => p.id === formData.security_header_profile_id)
if (!selected) return null
return (
<div className="mt-2 flex items-center gap-2">
<SecurityScoreDisplay
score={selected.security_score}
size="sm"
showDetails={false}
/>
<span className="text-xs text-gray-400">
{selected.description}
</span>
</div>
)
})()}
{/* Mobile App Compatibility Warning for Strict/Paranoid profiles */}
{formData.security_header_profile_id && (() => {
const selected = securityProfiles?.find(p => p.id === formData.security_header_profile_id)
if (!selected) return null
const isRestrictive = selected.preset_type === 'strict' || selected.preset_type === 'paranoid'
if (!isRestrictive) return null
return (
<div className="mt-2 p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-yellow-400">Mobile App Compatibility Warning</p>
<p className="text-yellow-300/80 mt-1">
This security profile may break mobile apps like Radarr, Plex, Jellyfin, or Home Assistant companion apps.
Consider using "API-Friendly" or "Basic" for services accessed by mobile clients.
</p>
</div>
</div>
</div>
)
})()}
<p className="text-xs text-gray-500 mt-1">
Apply HTTP security headers to protect against common web vulnerabilities.{' '}
<a
href="/security-headers"
target="_blank"
className="text-blue-400 hover:text-blue-300"
>
Manage Profiles
</a>
</p>
</div>
{/* Application Preset */}
<div>
<label htmlFor="application-preset" className="block text-sm font-medium text-gray-300 mb-2">
Application Preset
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
</label>
<select
id="application-preset"
value={formData.application}
onChange={e => {
const preset = e.target.value as ApplicationPreset
// Apply with advanced_config logic
const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(preset)
// Delegate to shared logic which will auto-apply or prompt
tryApplyPreset(preset)
// Ensure we still enable websockets when preset implies it
setFormData(prev => ({ ...prev, websocket_support: needsWebsockets || prev.websocket_support }))
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{APPLICATION_PRESETS.map(preset => (
<option key={preset.value} value={preset.value}>
{preset.label} - {preset.description}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Presets automatically configure headers for remote access behind tunnels/CGNAT.
</p>
</div>
{/* Application Config Helper */}
{formData.application !== 'none' && formData.domain_names && (
<div className="bg-blue-900/20 border border-blue-700 rounded-lg p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<h4 className="text-sm font-semibold text-blue-300">
{formData.application === 'plex' && 'Plex Remote Access Setup'}
{formData.application === 'jellyfin' && 'Jellyfin Proxy Setup'}
{formData.application === 'emby' && 'Emby Proxy Setup'}
{formData.application === 'homeassistant' && 'Home Assistant Proxy Setup'}
{formData.application === 'nextcloud' && 'Nextcloud Proxy Setup'}
{formData.application === 'vaultwarden' && 'Vaultwarden Setup'}
</h4>
{/* Plex Helper */}
{formData.application === 'plex' && (
<>
<p className="text-xs text-gray-300">
Copy this URL and paste it into <strong>Plex Settings Network Custom server access URLs</strong>
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 font-mono">
{getExternalUrl()}
</code>
<button
type="button"
onClick={() => copyToClipboard(getExternalUrl(), 'plex-url')}
className="px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-1"
>
{copiedField === 'plex-url' ? <Check size={14} /> : <Copy size={14} />}
{copiedField === 'plex-url' ? 'Copied!' : 'Copy'}
</button>
</div>
{charonInternalIP && (
<div className="mt-3">
<p className="text-xs text-gray-300">
Add this IP to <strong>Plex Settings Network List of IP addresses that are considered local</strong> or as a trusted proxy to ensure Plex displays remote clients correctly.
</p>
<div className="flex items-center gap-2 mt-2">
<code className="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 font-mono">
{charonInternalIP}
</code>
<button
type="button"
onClick={() => copyToClipboard(charonInternalIP, 'plex-proxy-ip')}
className="px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-1"
>
{copiedField === 'plex-proxy-ip' ? <Check size={14} /> : <Copy size={14} />}
{copiedField === 'plex-proxy-ip' ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
)}
</>
)}
{/* Jellyfin/Emby Helper */}
{(formData.application === 'jellyfin' || formData.application === 'emby') && charonInternalIP && (
<>
<p className="text-xs text-gray-300">
Add this IP to <strong>{formData.application === 'jellyfin' ? 'Jellyfin' : 'Emby'} Dashboard Networking Known Proxies</strong>
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 font-mono">
{charonInternalIP}
</code>
<button
type="button"
onClick={() => copyToClipboard(charonInternalIP, 'proxy-ip')}
className="px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-1"
>
{copiedField === 'proxy-ip' ? <Check size={14} /> : <Copy size={14} />}
{copiedField === 'proxy-ip' ? 'Copied!' : 'Copy'}
</button>
</div>
</>
)}
{/* Home Assistant Helper */}
{formData.application === 'homeassistant' && charonInternalIP && (
<>
<p className="text-xs text-gray-300">
Add this to your <strong>configuration.yaml</strong> under <code>http:</code>
</p>
<div className="relative">
<pre className="bg-gray-900 px-3 py-2 rounded text-sm text-green-400 font-mono overflow-x-auto">
{`http:
use_x_forwarded_for: true
trusted_proxies:
- ${charonInternalIP}`}
</pre>
<button
type="button"
onClick={() => copyToClipboard(`http:\n use_x_forwarded_for: true\n trusted_proxies:\n - ${charonInternalIP}`, 'ha-yaml')}
className="absolute top-2 right-2 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs flex items-center gap-1"
>
{copiedField === 'ha-yaml' ? <Check size={12} /> : <Copy size={12} />}
{copiedField === 'ha-yaml' ? 'Copied!' : 'Copy'}
</button>
</div>
</>
)}
{/* Nextcloud Helper */}
{formData.application === 'nextcloud' && charonInternalIP && (
<>
<p className="text-xs text-gray-300">
Add this to your <strong>config/config.php</strong>
</p>
<div className="relative">
<pre className="bg-gray-900 px-3 py-2 rounded text-sm text-green-400 font-mono overflow-x-auto">
{`'trusted_proxies' => ['${charonInternalIP}'],
'overwriteprotocol' => 'https',`}
</pre>
<button
type="button"
onClick={() => copyToClipboard(`'trusted_proxies' => ['${charonInternalIP}'],\n'overwriteprotocol' => 'https',`, 'nc-php')}
className="absolute top-2 right-2 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs flex items-center gap-1"
>
{copiedField === 'nc-php' ? <Check size={12} /> : <Copy size={12} />}
{copiedField === 'nc-php' ? 'Copied!' : 'Copy'}
</button>
</div>
</>
)}
{/* Vaultwarden Helper */}
{formData.application === 'vaultwarden' && (
<p className="text-xs text-gray-300">
WebSocket support is enabled automatically for live sync. Ensure your Bitwarden clients use this domain: <code className="text-green-400">{formData.domain_names.split(',')[0]?.trim()}</code>
</p>
)}
</div>
</div>
</div>
)}
{/* SSL & Security Options */}
<div className="space-y-3">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.ssl_forced}
onChange={e => setFormData({ ...formData, ssl_forced: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Force SSL</span>
<div title="Redirects visitors to the secure HTTPS version of your site. You should almost always turn this on to protect your data." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.http2_support}
onChange={e => setFormData({ ...formData, http2_support: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HTTP/2 Support</span>
<div title="Makes your site load faster by using a modern connection standard. Safe to leave on for most sites." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.hsts_enabled}
onChange={e => setFormData({ ...formData, hsts_enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Enabled</span>
<div title="Tells browsers to REMEMBER to only use HTTPS for this site. Adds extra security but can be tricky if you ever want to go back to HTTP." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.hsts_subdomains}
onChange={e => setFormData({ ...formData, hsts_subdomains: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Subdomains</span>
<div title="Applies the HSTS rule to all subdomains (like blog.mysite.com). Only use this if ALL your subdomains are secure." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.block_exploits}
onChange={e => setFormData({ ...formData, block_exploits: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Block Exploits</span>
<div title="Automatically blocks common hacking attempts. Recommended to keep your site safe." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.websocket_support}
onChange={e => setFormData({ ...formData, websocket_support: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Websockets Support</span>
<div title="Needed for apps that update in real-time (like chat, notifications, or live status). If your app feels 'broken' or doesn't update, try turning this on." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.enable_standard_headers ?? true}
onChange={e => setFormData({ ...formData, enable_standard_headers: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Enable Standard Proxy Headers</span>
<div title="Adds X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-Port headers to help backend applications detect client IPs, enforce HTTPS, and generate correct URLs. Recommended for all proxy hosts. Existing hosts: disabled by default for backward compatibility." className="text-gray-500 hover:text-gray-300 cursor-help">
<CircleHelp size={14} />
</div>
</label>
</div>
{/* Legacy Headers Warning Banner */}
{host && (formData.enable_standard_headers === false) && (
<div className="bg-yellow-900/20 border border-yellow-600 rounded-lg p-3">
<div className="flex items-start gap-2">
<Info className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-yellow-400">Standard Proxy Headers Disabled</p>
<p className="text-yellow-300/80 mt-1">
This proxy host is using the legacy behavior (headers only with WebSocket support).
Enable this option to ensure backend applications receive client IP and protocol information.
</p>
</div>
</div>
</div>
)}
{/* Advanced Config */}
<div>
<label htmlFor="advanced-config" className="block text-sm font-medium text-gray-300 mb-2">
Advanced Caddy Config (Optional)
</label>
<div className="relative">
{host?.advanced_config_backup && (
<div className="absolute right-0 top-0 -translate-y-8">
<button
type="button"
onClick={handleRestoreBackup}
className="px-3 py-1 bg-gray-800 hover:bg-gray-700 text-white rounded text-xs"
>
Restore previous config
</button>
</div>
)}
<textarea
id="advanced-config"
value={formData.advanced_config}
onChange={e => setFormData({ ...formData, advanced_config: e.target.value })}
placeholder="Additional Caddy directives..."
rows={4}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Enabled Toggle */}
<div className="flex items-center justify-end pb-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm font-medium text-white">Enable Proxy Host</span>
</label>
</div>
{/* Uptime option */}
<div className="border-t border-gray-800 pt-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={addUptime}
onChange={e => setAddUptime(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Add Uptime monitoring for this host</span>
</label>
{addUptime && (
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<label className="block text-sm text-gray-400 mb-1">Check Interval (seconds)</label>
<input
type="number"
min={10}
value={uptimeInterval}
onChange={e => setUptimeInterval(parseInt(e.target.value || '60'))}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Max Retries</label>
<input
type="number"
min={1}
value={uptimeMaxRetries}
onChange={e => setUptimeMaxRetries(parseInt(e.target.value || '3'))}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
/>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
<button
type="button"
onClick={onCancel}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleTestConnection}
disabled={loading || testStatus === 'testing' || !formData.forward_host || !formData.forward_port}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 disabled:opacity-50 ${
testStatus === 'success' ? 'bg-green-600 hover:bg-green-500 text-white' :
testStatus === 'error' ? 'bg-red-600 hover:bg-red-500 text-white' :
'bg-gray-700 hover:bg-gray-600 text-white'
}`}
title="Test connection to the forward host"
>
{testStatus === 'testing' ? <Loader2 size={18} className="animate-spin" /> :
testStatus === 'success' ? <Check size={18} /> :
testStatus === 'error' ? <X size={18} /> :
'Test Connection'}
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
{/* New Domain Prompt Modal */}
{showDomainPrompt && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-[60]">
<div className="bg-gray-800 rounded-lg border border-gray-700 max-w-md w-full p-6 shadow-xl">
<div className="flex items-center gap-3 mb-4 text-blue-400">
<AlertCircle size={24} />
<h3 className="text-lg font-semibold text-white">New Base Domain Detected</h3>
</div>
<p className="text-gray-300 mb-4">
You are using a new base domain: <span className="font-mono font-bold text-white">{pendingDomain}</span>
</p>
<p className="text-gray-400 text-sm mb-6">
Would you like to save this to your domain list for easier selection in the future?
</p>
<div className="flex items-center gap-2 mb-6">
<input
type="checkbox"
id="dont-ask"
checked={dontAskAgain}
onChange={e => handleDontAskToggle(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-600 rounded focus:ring-blue-500"
/>
<label htmlFor="dont-ask" className="text-sm text-gray-400 select-none">
Don't ask me again
</label>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setShowDomainPrompt(false)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
>
No, thanks
</button>
<button
type="button"
onClick={handleSaveDomain}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors"
>
Yes, save it
</button>
</div>
</div>
</div>
)}
{/* Preset Overwrite Confirmation Modal */}
{showPresetConfirmModal && pendingPreset && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-[60]">
<div className="bg-gray-800 rounded-lg border border-gray-700 max-w-lg w-full p-6 shadow-xl">
<div className="flex items-center gap-3 mb-4 text-blue-400">
<AlertCircle size={24} />
<h3 className="text-lg font-semibold text-white">Confirm Preset Overwrite</h3>
</div>
<p className="text-gray-300 mb-4">
You already have an Advanced Caddy Config for this host. Applying the <strong>{pendingPreset}</strong> preset will overwrite the existing configuration.
</p>
<p className="text-gray-400 text-sm mb-4">A backup of your existing configuration will be created when the host is saved.</p>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">Preset Preview</label>
<pre className="bg-gray-900 px-3 py-2 rounded text-sm text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{PRESET_ADVANCED_CONFIG[pendingPreset]}
</pre>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={handleCancelPresetOverwrite}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirmPresetOverwrite}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors"
>
Overwrite
</button>
</div>
</div>
</div>
)}
</div>
</div>
</>
)
}