/**
* 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 { showModal } from '../utils/modal.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;
this.hasApiKey = false; // Track whether we have an API key without storing the key itself
}
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;
// Also fetch security status to determine if API key exists
const statusResponse = await this.api.makeRequest('/security/status', 'GET');
this.hasApiKey = statusResponse.has_api_key;
// Initialize actualApiKey for copy functionality (empty for security)
this.actualApiKey = '';
} 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: ''
};
this.hasApiKey = false;
this.actualApiKey = '';
}
}
maskApiKey(apiKey) {
// Completely mask the API key for display - show only dots
if (!apiKey) {
return '';
}
return '•'.repeat(Math.max(32, apiKey.length));
}
render() {
this.container.innerHTML = `
`;
}
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.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 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;
}
// Standard password field behavior (no special handling for API key anymore)
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) {
// Store the newly generated API key temporarily for the modal
const newApiKey = response.api_key;
// Show modal with the API key
const modalContent = `
Important: Copy this API key now. It will not be shown again.
This key provides full access to the qBit Manage API. Keep it secure!
`;
// Add copy functionality using event delegation before showing modal
const handleCopyClick = (event) => {
if (event.target && event.target.id === 'modal-copy-api-key') {
navigator.clipboard.writeText(newApiKey)
.then(() => showToast('API key copied to clipboard', 'success'))
.catch(() => showToast('Failed to copy API key', 'error'));
}
};
// Attach event listener to document for the copy button
document.addEventListener('click', handleCopyClick);
// Show the modal
const modalResult = await showModal('New API Key Generated', modalContent, {
confirmText: 'Close',
showCancel: false
});
// Clean up event listener after modal is closed
document.removeEventListener('click', handleCopyClick);
// Update the component state to reflect that we now have a key
this.hasApiKey = true;
// Re-render to update button states
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) {
// Clear the API key state
this.hasApiKey = false;
// Re-render to update button states
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');
}
}
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;
}
// For basic authentication, password is required
if (method === 'basic') {
const hasExistingPassword = this.currentSettings.password_hash && this.currentSettings.password_hash !== '';
const hasNewPassword = password && password.trim() !== '';
if (!hasExistingPassword && !hasNewPassword) {
showToast('Password is required for basic authentication', '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');
}
}
}
}