/** * Component: Download Client Configuration Modal * Documentation: documentation/phase3/download-clients.md */ 'use client'; import React, { useState, useEffect } from 'react'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { fetchWithAuth } from '@/lib/utils/api'; import { DownloadClientType, getClientDisplayName, CLIENT_PROTOCOL_MAP } from '@/lib/interfaces/download-client.interface'; interface DownloadClientModalProps { isOpen: boolean; onClose: () => void; mode: 'add' | 'edit'; clientType?: DownloadClientType; initialClient?: { id: string; type: DownloadClientType; name: string; url: string; username?: string; password: string; enabled: boolean; disableSSLVerify: boolean; remotePathMappingEnabled: boolean; remotePath?: string; localPath?: string; category?: string; customPath?: string; postImportCategory?: string; }; onSave: (client: any) => Promise; apiMode: 'wizard' | 'settings'; downloadDir?: string; } export function DownloadClientModal({ isOpen, onClose, mode, clientType, initialClient, onSave, apiMode, downloadDir = '/downloads', }: DownloadClientModalProps) { const type = mode === 'edit' ? initialClient?.type : clientType; const typeName = type ? getClientDisplayName(type) : ''; // Form state const [name, setName] = useState(''); const [url, setUrl] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [enabled, setEnabled] = useState(true); const [disableSSLVerify, setDisableSSLVerify] = useState(false); const [remotePathMappingEnabled, setRemotePathMappingEnabled] = useState(false); const [remotePath, setRemotePath] = useState(''); const [localPath, setLocalPath] = useState(''); const [category, setCategory] = useState('readmeabook'); const [customPath, setCustomPath] = useState(''); const [postImportCategory, setPostImportCategory] = useState(''); const [availableCategories, setAvailableCategories] = useState([]); const [fetchingCategories, setFetchingCategories] = useState(false); const [testing, setTesting] = useState(false); const [saving, setSaving] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [errors, setErrors] = useState>({}); // Reset form when modal opens useEffect(() => { if (isOpen) { if (mode === 'edit' && initialClient) { setName(initialClient.name); setUrl(initialClient.url); setUsername(initialClient.username || ''); // In wizard mode, use actual password from local state // In settings mode, mask password (server doesn't send real passwords) setPassword(apiMode === 'wizard' ? initialClient.password : '********'); setEnabled(initialClient.enabled); setDisableSSLVerify(initialClient.disableSSLVerify); setRemotePathMappingEnabled(initialClient.remotePathMappingEnabled); setRemotePath(initialClient.remotePath || ''); setLocalPath(initialClient.localPath || ''); setCategory(initialClient.category || 'readmeabook'); setCustomPath(initialClient.customPath || ''); setPostImportCategory(initialClient.postImportCategory || ''); } else { // Add mode defaults setName(typeName); setUrl(''); setUsername(''); setPassword(''); setEnabled(true); setDisableSSLVerify(false); setRemotePathMappingEnabled(false); setRemotePath(''); setLocalPath(''); setCategory('readmeabook'); setCustomPath(''); setPostImportCategory(''); } setTestResult(null); setErrors({}); setAvailableCategories([]); setFetchingCategories(false); } }, [isOpen, mode, initialClient, type]); const validate = () => { const newErrors: Record = {}; if (!name.trim()) { newErrors.name = 'Name is required'; } if (!url.trim()) { newErrors.url = 'URL is required'; } // SABnzbd always requires API key; qBittorrent credentials are optional (supports IP whitelist auth) if (type === 'sabnzbd' && (!password.trim() || (mode === 'add' && password === '********'))) { newErrors.password = 'API key is required'; } if (customPath.includes('..')) { newErrors.customPath = 'Path cannot contain ".."'; } if (remotePathMappingEnabled) { if (!remotePath.trim()) { newErrors.remotePath = 'Remote path is required when path mapping is enabled'; } if (!localPath.trim()) { newErrors.localPath = 'Local path is required when path mapping is enabled'; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const fetchCategories = async () => { setFetchingCategories(true); try { const isPasswordMasked = password === '********'; const categoryData = { type, name, url, username: username || undefined, password: isPasswordMasked ? undefined : password, ...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}), disableSSLVerify, remotePathMappingEnabled, remotePath: remotePathMappingEnabled ? remotePath : undefined, localPath: remotePathMappingEnabled ? localPath : undefined, }; const endpoint = apiMode === 'wizard' ? '/api/setup/download-client-categories' : '/api/admin/settings/download-clients/categories'; const response = apiMode === 'wizard' ? await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(categoryData), }) : await fetchWithAuth(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(categoryData), }); const data = await response.json(); if (response.ok && data.success) { setAvailableCategories(data.categories || []); } } catch { // Non-critical — categories are optional } finally { setFetchingCategories(false); } }; const handleTestConnection = async () => { if (!validate()) { return; } setTesting(true); setTestResult(null); try { // If editing and password is masked, send clientId so server uses stored password const isPasswordMasked = password === '********'; const testData = { type, name, url, username: username || undefined, password: isPasswordMasked ? undefined : password, // Include clientId when editing so server can use stored password ...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}), disableSSLVerify, remotePathMappingEnabled, remotePath: remotePathMappingEnabled ? remotePath : undefined, localPath: remotePathMappingEnabled ? localPath : undefined, }; const endpoint = apiMode === 'wizard' ? '/api/setup/test-download-client' : '/api/admin/settings/download-clients/test'; // Wizard mode: no auth required (public endpoint during setup) // Settings mode: use fetchWithAuth to include JWT token const response = apiMode === 'wizard' ? await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(testData), }) : await fetchWithAuth(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(testData), }); const data = await response.json(); if (response.ok && data.success) { // Handle both endpoint response formats (settings returns message, wizard returns version) const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful'); setTestResult({ success: true, message }); // Fetch categories for torrent clients after successful connection if (type && CLIENT_PROTOCOL_MAP[type] === 'torrent') { fetchCategories(); } } else { setTestResult({ success: false, message: data.error || 'Connection test failed' }); } } catch (error) { setTestResult({ success: false, message: error instanceof Error ? error.message : 'Connection test failed', }); } finally { setTesting(false); } }; const handleSave = async () => { if (!validate()) { return; } // Skip connection test requirement when disabling the client if (!testResult?.success && enabled) { setErrors({ ...errors, test: 'Please test the connection before saving' }); return; } setSaving(true); try { // Strip leading/trailing slashes from customPath const sanitizedCustomPath = customPath.replace(/^\/+|\/+$/g, '').trim(); const clientData: any = { type, name, url, username: type !== 'sabnzbd' ? username : undefined, password: password === '********' ? undefined : password, // Don't send masked password on edit enabled, disableSSLVerify, remotePathMappingEnabled, remotePath: remotePathMappingEnabled ? remotePath : undefined, localPath: remotePathMappingEnabled ? localPath : undefined, category, customPath: sanitizedCustomPath || undefined, postImportCategory, }; if (mode === 'edit' && initialClient) { clientData.id = initialClient.id; } await onSave(clientData); onClose(); } catch (error) { setErrors({ ...errors, save: error instanceof Error ? error.message : 'Failed to save client', }); } finally { setSaving(false); } }; return (
{/* Name */}
setName(e.target.value)} placeholder={`My ${typeName}`} error={errors.name} />

Friendly name to identify this client

{/* URL */}
setUrl(e.target.value)} placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'} error={errors.url} />

Web UI URL (e.g., http://localhost:8080)

{/* Username (qBittorrent and Transmission) */} {type !== 'sabnzbd' && (
setUsername(e.target.value)} placeholder="admin" error={errors.username} />
)} {/* Password / API Key */}
setPassword(e.target.value)} placeholder={type === 'sabnzbd' ? 'API Key from SABnzbd Config > General' : 'Password'} error={errors.password} /> {type === 'sabnzbd' && (

Found in SABnzbd under Config → General → API Key

)} {type === 'nzbget' && (

Configured in NZBGet under Settings → Security → ControlPassword

)}
{/* SSL Verification */} {url.startsWith('https://') && (
setDisableSSLVerify(e.target.checked)} className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
)} {/* Enabled Toggle */}
setEnabled(e.target.checked)} className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
{/* Custom Download Path */}
setCustomPath(e.target.value)} placeholder="e.g. torrents or usenet/books" error={errors.customPath} />

Optional relative sub-path appended to the base download directory

Downloads to: {customPath.replace(/^\/+|\/+$/g, '').trim() ? `${downloadDir}/${customPath.replace(/^\/+|\/+$/g, '').trim()}` : downloadDir}

{/* Post-Import Category (torrent clients only) */} {type && CLIENT_PROTOCOL_MAP[type] === 'torrent' && (
{type === 'qbittorrent' && availableCategories.length > 0 ? ( ) : ( setPostImportCategory(e.target.value)} placeholder="e.g. completed" disabled={fetchingCategories} /> )}

After import, change the download's category/label in the client. Leave empty to skip.

)} {/* Remote Path Mapping */}
setRemotePathMappingEnabled(e.target.checked)} className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
{remotePathMappingEnabled && (
setRemotePath(e.target.value)} placeholder="F:\Docker\downloads\completed\books" error={errors.remotePath} />

Path as seen by {typeName}

setLocalPath(e.target.value)} placeholder="/downloads" error={errors.localPath} />

Path as seen by ReadMeABook

)}
{/* Test Result */} {testResult && (

{testResult.message}

)} {/* Errors */} {errors.test && (

{errors.test}

)} {errors.save && (

{errors.save}

)} {/* Action Buttons */}
); }