feat: Enhance ProxyHost configuration with application presets and internal IP support
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CircleHelp, AlertCircle, Check, X, Loader2 } from 'lucide-react'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import { CircleHelp, AlertCircle, Check, X, Loader2, Copy, Info } from 'lucide-react'
|
||||
import type { ProxyHost, ApplicationPreset } from '../api/proxyHosts'
|
||||
import { testProxyHostConnection } from '../api/proxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { useDomains } from '../hooks/useDomains'
|
||||
@@ -8,6 +8,32 @@ import { useCertificates } from '../hooks/useCertificates'
|
||||
import { useDocker } from '../hooks/useDocker'
|
||||
import { parse } from 'tldts'
|
||||
|
||||
// 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',
|
||||
}
|
||||
|
||||
interface ProxyHostFormProps {
|
||||
host?: ProxyHost
|
||||
onSubmit: (data: Partial<ProxyHost>) => Promise<void>
|
||||
@@ -27,11 +53,57 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
hsts_subdomains: host?.hsts_subdomains ?? true,
|
||||
block_exploits: host?.block_exploits ?? true,
|
||||
websocket_support: host?.websocket_support ?? true,
|
||||
application: (host?.application || 'none') as ApplicationPreset,
|
||||
advanced_config: host?.advanced_config || '',
|
||||
enabled: host?.enabled ?? true,
|
||||
certificate_id: host?.certificate_id,
|
||||
})
|
||||
|
||||
// CPMP internal IP for config helpers
|
||||
const [cpmpInternalIP, setCpmpInternalIP] = useState<string>('')
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
|
||||
// Fetch CPMP internal IP on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/health')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.internal_ip) {
|
||||
setCpmpInternalIP(data.internal_ip)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 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 {
|
||||
console.error('Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -184,12 +256,19 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
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)
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
forward_host: host,
|
||||
forward_port: port,
|
||||
forward_scheme: 'http',
|
||||
domain_names: newDomainNames,
|
||||
application: detectedPreset,
|
||||
websocket_support: needsWebsockets || formData.websocket_support,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -411,6 +490,156 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
</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
|
||||
// Auto-enable websockets for apps that need it
|
||||
const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(preset)
|
||||
setFormData({
|
||||
...formData,
|
||||
application: preset,
|
||||
websocket_support: needsWebsockets || formData.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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Jellyfin/Emby Helper */}
|
||||
{(formData.application === 'jellyfin' || formData.application === 'emby') && cpmpInternalIP && (
|
||||
<>
|
||||
<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">
|
||||
{cpmpInternalIP}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(cpmpInternalIP, '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' && cpmpInternalIP && (
|
||||
<>
|
||||
<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:
|
||||
- ${cpmpInternalIP}`}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(`http:\n use_x_forwarded_for: true\n trusted_proxies:\n - ${cpmpInternalIP}`, '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' && cpmpInternalIP && (
|
||||
<>
|
||||
<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' => ['${cpmpInternalIP}'],
|
||||
'overwriteprotocol' => 'https',`}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(`'trusted_proxies' => ['${cpmpInternalIP}'],\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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
@@ -71,6 +71,10 @@ vi.mock('../../api/proxyHosts', () => ({
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock global fetch for health API
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -93,6 +97,13 @@ describe('ProxyHostForm', () => {
|
||||
const mockOnSubmit = vi.fn((_data: any) => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
// Default fetch mock for health endpoint
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ internal_ip: '192.168.1.50' }),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -234,4 +245,305 @@ describe('ProxyHostForm', () => {
|
||||
|
||||
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
|
||||
})
|
||||
|
||||
// Application Preset Tests
|
||||
describe('Application Presets', () => {
|
||||
it('renders application preset dropdown with all options', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const presetSelect = screen.getByLabelText(/Application Preset/i)
|
||||
expect(presetSelect).toBeInTheDocument()
|
||||
|
||||
// Check that all presets are available
|
||||
expect(screen.getByText('None - Standard reverse proxy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plex - Media server with remote access')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jellyfin - Open source media server')).toBeInTheDocument()
|
||||
expect(screen.getByText('Emby - Media server')).toBeInTheDocument()
|
||||
expect(screen.getByText('Home Assistant - Home automation')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nextcloud - File sync and share')).toBeInTheDocument()
|
||||
expect(screen.getByText('Vaultwarden - Password manager')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to none preset', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const presetSelect = screen.getByLabelText(/Application Preset/i)
|
||||
expect(presetSelect).toHaveValue('none')
|
||||
})
|
||||
|
||||
it('enables websockets when selecting plex preset', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// First uncheck websockets
|
||||
const websocketCheckbox = screen.getByLabelText(/Websockets Support/i)
|
||||
if (websocketCheckbox.getAttribute('checked') !== null) {
|
||||
fireEvent.click(websocketCheckbox)
|
||||
}
|
||||
|
||||
// Select Plex preset
|
||||
fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'plex' } })
|
||||
|
||||
// Websockets should be enabled
|
||||
expect(screen.getByLabelText(/Websockets Support/i)).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows plex config helper with external URL when preset is selected', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
|
||||
target: { value: 'plex.mydomain.com' }
|
||||
})
|
||||
|
||||
// Select Plex preset
|
||||
fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'plex' } })
|
||||
|
||||
// Should show the helper with external URL
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://plex.mydomain.com:443')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows jellyfin config helper with internal IP', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
|
||||
target: { value: 'jellyfin.mydomain.com' }
|
||||
})
|
||||
|
||||
// Select Jellyfin preset
|
||||
fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'jellyfin' } })
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Jellyfin Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.50')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows home assistant config helper with yaml snippet', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
|
||||
target: { value: 'ha.mydomain.com' }
|
||||
})
|
||||
|
||||
// Select Home Assistant preset
|
||||
fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'homeassistant' } })
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Home Assistant Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/use_x_forwarded_for/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/192\.168\.1\.50/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows nextcloud config helper with php snippet', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
|
||||
target: { value: 'nextcloud.mydomain.com' }
|
||||
})
|
||||
|
||||
// Select Nextcloud preset
|
||||
fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'nextcloud' } })
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nextcloud Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/trusted_proxies/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/overwriteprotocol/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows vaultwarden helper text', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
|
||||
target: { value: 'vault.mydomain.com' }
|
||||
})
|
||||
|
||||
// Select Vaultwarden preset
|
||||
fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'vaultwarden' } })
|
||||
|
||||
// Wait for helper text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Vaultwarden Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/WebSocket support is enabled automatically/)).toBeInTheDocument()
|
||||
expect(screen.getByText('vault.mydomain.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-detects plex preset from container image', async () => {
|
||||
// Mock useDocker to return a Plex container
|
||||
const { useDocker } = await import('../../hooks/useDocker')
|
||||
vi.mocked(useDocker).mockReturnValue({
|
||||
containers: [
|
||||
{
|
||||
id: 'plex-container',
|
||||
names: ['plex'],
|
||||
image: 'linuxserver/plex:latest',
|
||||
state: 'running',
|
||||
status: 'Up 1 hour',
|
||||
network: 'bridge',
|
||||
ip: '172.17.0.3',
|
||||
ports: [{ private_port: 32400, public_port: 32400, type: 'tcp' }]
|
||||
}
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Select local source
|
||||
fireEvent.change(screen.getByLabelText('Source'), { target: { value: 'local' } })
|
||||
|
||||
// Select the plex container
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plex (linuxserver/plex:latest)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Containers'), { target: { value: 'plex-container' } })
|
||||
|
||||
// The preset should be auto-detected as plex
|
||||
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
|
||||
})
|
||||
|
||||
it('includes application field in form submission', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
fireEvent.change(screen.getByPlaceholderText('My Service'), { target: { value: 'My Plex Server' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), { target: { value: 'plex.test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Host$/), { target: { value: '192.168.1.100' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Port$/), { target: { value: '32400' } })
|
||||
|
||||
// Select Plex preset
|
||||
fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'plex' } })
|
||||
|
||||
// Submit form
|
||||
fireEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
application: 'plex',
|
||||
websocket_support: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('loads existing host application preset', async () => {
|
||||
const existingHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'Existing Plex',
|
||||
domain_names: 'plex.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.100',
|
||||
forward_port: 32400,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
application: 'plex' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// The preset should be pre-selected
|
||||
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
|
||||
|
||||
// The config helper should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show config helper when preset is none', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
|
||||
target: { value: 'test.mydomain.com' }
|
||||
})
|
||||
|
||||
// Preset defaults to none, so no helper should be shown
|
||||
expect(screen.queryByText('Plex Remote Access Setup')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Jellyfin Proxy Setup')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Home Assistant Proxy Setup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies external URL to clipboard for plex', async () => {
|
||||
// Mock clipboard API
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: mockWriteText },
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
|
||||
target: { value: 'plex.mydomain.com' }
|
||||
})
|
||||
|
||||
// Select Plex preset
|
||||
fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'plex' } })
|
||||
|
||||
// Wait for helper to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the copy button
|
||||
const copyButtons = screen.getAllByText('Copy')
|
||||
fireEvent.click(copyButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('https://plex.mydomain.com:443')
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user