/** * Security Component for qBit Manage Web UI * Handles authentication settings and user management */ import { API } from '../api.js'; import { showToast } from '../utils/toast.js'; import { EYE_ICON_SVG, EYE_SLASH_ICON_SVG } from '../utils/icons.js'; export class SecurityComponent { constructor(containerId, apiInstance = null) { this.container = document.getElementById(containerId); this.api = apiInstance || new API(); this.currentSettings = null; } async init() { try { await this.loadSecuritySettings(); this.render(); this.bindEvents(); } catch (error) { showToast('Failed to load security settings', 'error'); } } async loadSecuritySettings() { try { const response = await this.api.makeRequest('/security', 'GET'); this.currentSettings = response; } catch (error) { // Use default settings if loading fails this.currentSettings = { enabled: false, method: 'none', bypass_auth_for_local: false, trusted_proxies: [], username: '', password_hash: '', api_key: '' }; } } render() { this.container.innerHTML = `

Security Settings

Configure authentication for the qBit Manage web interface. Choose between no authentication or basic HTTP authentication.

None: No authentication required
Basic: Browser popup for username/password authentication
API Only: No auth for web UI, API key required for API requests
When checked, requests from localhost and RFC 1918 private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) won't require authentication
List of trusted proxy IPs or subnets (one per line). When set, the app will trust X-Forwarded-For headers from these proxies to determine the real client IP for local bypass decisions. Leave empty for direct connections.
Format: IPv4 addresses (e.g., 192.168.1.1) or CIDR notation (e.g., 192.168.1.0/24, 10.0.0.0/8)

Credentials

Username for authentication
Leave blank to keep current password
Must match the password above

API Key

${this.currentSettings.api_key ? ` ` : ''}
${this.currentSettings.api_key ? ` ` : ''}
API key for programmatic access. Click "Generate New Key" to create a new one.
`; } bindEvents() { // Cache DOM elements for better performance this.authMethod = document.getElementById('auth-method'); this.localAddressesGroup = document.getElementById('local-addresses-group'); this.trustedProxiesGroup = document.getElementById('trusted-proxies-group'); this.credentialsSection = document.getElementById('credentials-section'); this.generateApiKeyBtn = document.getElementById('generate-api-key'); this.clearApiKeyBtn = document.getElementById('clear-api-key'); this.copyApiKeyBtn = document.getElementById('copy-api-key'); this.saveBtn = document.getElementById('save-security-settings'); // Handle authentication method change if (this.authMethod) { this.authMethod.addEventListener('change', () => { this.handleMethodChange(this.authMethod.value); }); } // Handle password visibility toggles const passwordToggles = document.querySelectorAll('.password-toggle'); passwordToggles.forEach(toggle => { toggle.addEventListener('click', (e) => { const targetId = toggle.dataset.target; this.togglePasswordVisibility(targetId); }); }); // Handle generate API key if (this.generateApiKeyBtn) { this.generateApiKeyBtn.addEventListener('click', () => { this.generateApiKey(); }); } // Handle clear API key if (this.clearApiKeyBtn) { this.clearApiKeyBtn.addEventListener('click', () => { this.clearApiKey(); }); } // Handle copy API key if (this.copyApiKeyBtn) { this.copyApiKeyBtn.addEventListener('click', () => { this.copyApiKey(); }); } // Handle save settings if (this.saveBtn) { this.saveBtn.addEventListener('click', () => { this.saveSettings(); }); } } handleMethodChange(method) { const isNone = method === 'none'; const isApiOnly = method === 'api_only'; if (this.localAddressesGroup) { this.localAddressesGroup.style.display = isNone ? 'none' : ''; } if (this.trustedProxiesGroup) { this.trustedProxiesGroup.style.display = isNone ? 'none' : ''; } if (this.credentialsSection) { this.credentialsSection.style.display = (isNone || isApiOnly) ? 'none' : ''; } } togglePasswordVisibility(targetId) { const input = document.getElementById(targetId); const button = document.querySelector(`[data-target="${targetId}"]`); if (!input || !button) { console.error('Password input or toggle button not found:', targetId); return; } const isPassword = input.type === 'password'; input.type = isPassword ? 'text' : 'password'; button.innerHTML = isPassword ? EYE_SLASH_ICON_SVG : EYE_ICON_SVG; } // Removed maskApiKey and toggleApiKeyVisibility methods - API key is always shown in full async generateApiKey() { if (!confirm('Are you sure you want to generate a new API key? The old key will no longer work.')) { return; } try { // Get current form values to send complete request const method = document.getElementById('auth-method').value; const allowLocalCheckbox = document.getElementById('allow-local'); const allowLocalIps = allowLocalCheckbox && allowLocalCheckbox.checked; const trustedProxiesTextarea = document.getElementById('trusted-proxies'); const trustedProxies = trustedProxiesTextarea ? trustedProxiesTextarea.value.split('\n').map(line => line.trim()).filter(line => line) : []; const username = document.getElementById('username')?.value.trim() || ''; const password = document.getElementById('password')?.value || ''; const requestData = { enabled: method !== 'none', method: method, bypass_auth_for_local: allowLocalIps, trusted_proxies: trustedProxies, username: username, password: password, generate_api_key: true }; const response = await this.api.makeRequest('/security', 'PUT', requestData); if (response.success) { this.currentSettings.api_key = response.api_key; const input = document.getElementById('api-key-display'); if (input) { input.value = response.api_key; } // Re-render to update button visibility this.render(); this.bindEvents(); showToast('New API key generated successfully', 'success'); } else { showToast(response.message || 'Failed to generate API key', 'error'); } } catch (error) { console.error('Failed to generate API key:', error); showToast('Failed to generate API key', 'error'); } } async clearApiKey() { if (!confirm('Are you sure you want to clear the API key? This will disable API access until a new key is generated.')) { return; } try { // Get current form values to send complete request const method = document.getElementById('auth-method').value; const allowLocalCheckbox = document.getElementById('allow-local'); const allowLocalIps = allowLocalCheckbox && allowLocalCheckbox.checked; const trustedProxiesTextarea = document.getElementById('trusted-proxies'); const trustedProxies = trustedProxiesTextarea ? trustedProxiesTextarea.value.split('\n').map(line => line.trim()).filter(line => line) : []; const username = document.getElementById('username')?.value.trim() || ''; const password = document.getElementById('password')?.value || ''; const requestData = { enabled: method !== 'none', method: method, bypass_auth_for_local: allowLocalIps, trusted_proxies: trustedProxies, username: username, password: password, clear_api_key: true }; const response = await this.api.makeRequest('/security', 'PUT', requestData); if (response.success) { this.currentSettings.api_key = ''; const input = document.getElementById('api-key-display'); if (input) { input.value = ''; } // Re-render to update button visibility this.render(); this.bindEvents(); showToast('API key cleared successfully', 'success'); } else { showToast(response.message || 'Failed to clear API key', 'error'); } } catch (error) { console.error('Failed to clear API key:', error); showToast('Failed to clear API key', 'error'); } } copyApiKey() { const input = document.getElementById('api-key-display'); if (!input) return; navigator.clipboard.writeText(input.value) .then(() => showToast('API key copied to clipboard', 'success')) .catch(() => showToast('Failed to copy API key', 'error')); } validatePasswordComplexity(password) { const minLength = 8; const hasUpper = /[A-Z]/.test(password); const hasLower = /[a-z]/.test(password); const hasNumber = /\d/.test(password); const hasSpecial = /[!@#$%^&*]/.test(password); // Count how many character types are present const typeCount = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length; return password.length >= minLength && typeCount >= 3; } validateIpOrCidr(ip) { // Check if it's a valid IPv4 address const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; const match = ip.match(ipv4Regex); if (match) { // Check if it's a valid IP address (each octet 0-255) for (let i = 1; i <= 4; i++) { const octet = parseInt(match[i], 10); if (octet < 0 || octet > 255) { return false; } } return true; } // Check if it's a valid CIDR notation const cidrRegex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/; const cidrMatch = ip.match(cidrRegex); if (cidrMatch) { // Validate IP part for (let i = 1; i <= 4; i++) { const octet = parseInt(cidrMatch[i], 10); if (octet < 0 || octet > 255) { return false; } } // Validate subnet mask (0-32) const mask = parseInt(cidrMatch[5], 10); return mask >= 0 && mask <= 32; } return false; } validateTrustedProxies(proxies) { if (!proxies || proxies.length === 0) { return { valid: true }; // Empty is valid } const invalidEntries = []; const duplicates = []; for (let i = 0; i < proxies.length; i++) { const proxy = proxies[i].trim(); if (!proxy) continue; // Skip empty lines // Check format if (!this.validateIpOrCidr(proxy)) { invalidEntries.push(proxy); } // Check for duplicates if (proxies.indexOf(proxy) !== i) { if (!duplicates.includes(proxy)) { duplicates.push(proxy); } } } if (invalidEntries.length > 0 || duplicates.length > 0) { return { valid: false, invalidEntries, duplicates }; } return { valid: true }; } async saveSettings() { try { const method = document.getElementById('auth-method').value; const allowLocalCheckbox = document.getElementById('allow-local'); const allowLocalIps = allowLocalCheckbox && allowLocalCheckbox.checked; const trustedProxiesTextarea = document.getElementById('trusted-proxies'); const trustedProxies = trustedProxiesTextarea ? trustedProxiesTextarea.value.split('\n').map(line => line.trim()).filter(line => line) : []; const username = document.getElementById('username')?.value.trim() || ''; const password = document.getElementById('password')?.value || ''; const confirmPassword = document.getElementById('confirm-password')?.value || ''; // Validate trusted proxies const proxyValidation = this.validateTrustedProxies(trustedProxies); if (!proxyValidation.valid) { let errorMessage = 'Invalid trusted proxy entries:'; if (proxyValidation.invalidEntries.length > 0) { errorMessage += `\n• Invalid format: ${proxyValidation.invalidEntries.join(', ')}`; errorMessage += '\n (Use IPv4 addresses like 192.168.1.1 or CIDR notation like 192.168.1.0/24)'; } if (proxyValidation.duplicates.length > 0) { errorMessage += `\n• Duplicates found: ${proxyValidation.duplicates.join(', ')}`; } showToast(errorMessage, 'error'); return; } // Validation if (method !== 'none' && method !== 'api_only') { if (!username) { showToast('Username is required', 'error'); return; } if (password && password !== confirmPassword) { showToast('Passwords do not match', 'error'); return; } // Password complexity validation if (password && !this.validatePasswordComplexity(password)) { showToast('Password must be at least 8 characters with at least 3 of: uppercase, lowercase, number, special character', 'error'); return; } } // Prepare request data const requestData = { enabled: method !== 'none', method: method, bypass_auth_for_local: allowLocalIps, trusted_proxies: trustedProxies, username: username, password: password, generate_api_key: false }; // Make API request const response = await this.api.makeRequest('/security', 'PUT', requestData); if (response.success) { showToast('Security settings saved successfully', 'success'); // Update current settings this.currentSettings = { enabled: method !== 'none', method: method, bypass_auth_for_local: allowLocalIps, trusted_proxies: trustedProxies, username: username, password_hash: password ? '***' : this.currentSettings.password_hash, api_key: this.currentSettings.api_key }; // Clear password fields const passwordField = document.getElementById('password'); const confirmPasswordField = document.getElementById('confirm-password'); if (passwordField) passwordField.value = ''; if (confirmPasswordField) confirmPasswordField.value = ''; } else { showToast(response.message || 'Failed to save settings', 'error'); } } catch (error) { console.error('Failed to save security settings:', error); showToast('Failed to save security settings', 'error'); } } async resetSettings() { if (confirm('Are you sure you want to reset security settings? This will disable authentication.')) { try { await this.loadSecuritySettings(); this.render(); this.bindEvents(); showToast('Security settings reset', 'info'); } catch (error) { console.error('Failed to reset settings:', error); showToast('Failed to reset settings', 'error'); } } } }