From 4040d80686491f08dfe163f5fe6fdda0deb4c048 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 06:47:49 +0000 Subject: [PATCH 01/11] Bump cryptography from 45.0.7 to 46.0.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.7 to 46.0.1. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.7...46.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2484095..691bca1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,4 @@ pyotp==2.9.0 qrcode==8.2 pillow==11.3.0 requests==2.32.5 -cryptography==45.0.7 \ No newline at end of file +cryptography==46.0.1 \ No newline at end of file From e0dc06352e93f7745cd7e350c335a201b0a91d5f Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 13:50:38 +0200 Subject: [PATCH 02/11] Multi-domain feature added --- app/directadmin_api.py | 66 ++++++++-- app/main.py | 141 +++++++++++++++------ app/models.py | 89 ++++++++++++- app/settings.py | 136 +++++++++++++++++++- app/templates/dashboard.html | 12 +- app/templates/settings.html | 94 +++++--------- static/dashboard.js | 126 +++++++++++++++++-- static/settings.js | 234 +++++++++++++++++++++++++++-------- static/style.css | 131 ++++++++++++++++++++ 9 files changed, 837 insertions(+), 192 deletions(-) diff --git a/app/directadmin_api.py b/app/directadmin_api.py index b927be5..5e61958 100644 --- a/app/directadmin_api.py +++ b/app/directadmin_api.py @@ -69,7 +69,7 @@ def _make_request(self, endpoint, data=None, method='POST'): # Format 1: URL encoded (key=value&key2=value2) if '=' in text and not text.startswith('' + + def to_dict(self): + return { + 'id': self.id, + 'domain': self.domain, + 'order_index': self.order_index, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + class User(UserMixin, db.Model): # Primary fields id = db.Column(db.Integer, primary_key=True) @@ -95,9 +117,67 @@ def has_da_config(self): return all([ self.da_server, self.da_username, - self.da_password_encrypted, - self.da_domain - ]) + self.da_password_encrypted + ]) and len(self.get_domains()) > 0 + + # ===== Domain Management ===== + + def get_domains(self): + """Get all domains for this user in order""" + return [d.domain for d in self.domains] + + def get_first_domain(self): + """Get the first domain (default) for this user""" + domains = self.get_domains() + return domains[0] if domains else None + + def add_domain(self, domain): + """Add a new domain for this user""" + # Check if domain already exists + existing = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() + if existing: + return False, "Domain already exists" + + # Get next order index + max_order = db.session.query(db.func.max(UserDomain.order_index)).filter_by(user_id=self.id).scalar() + next_order = (max_order or -1) + 1 + + # Create new domain + user_domain = UserDomain( + user_id=self.id, + domain=domain, + order_index=next_order + ) + + db.session.add(user_domain) + return True, "Domain added successfully" + + def remove_domain(self, domain): + """Remove a domain for this user""" + user_domain = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() + if not user_domain: + return False, "Domain not found" + + db.session.delete(user_domain) + + # Reorder remaining domains + remaining_domains = UserDomain.query.filter_by(user_id=self.id).filter( + UserDomain.order_index > user_domain.order_index + ).all() + + for domain_obj in remaining_domains: + domain_obj.order_index -= 1 + + return True, "Domain removed successfully" + + def reorder_domains(self, domain_list): + """Reorder domains based on provided list""" + for i, domain in enumerate(domain_list): + user_domain = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() + if user_domain: + user_domain.order_index = i + + return True, "Domains reordered successfully" # ===== TOTP/2FA Management ===== @@ -155,7 +235,8 @@ def to_dict(self): 'has_da_config': self.has_da_config(), 'da_server': self.da_server, 'da_username': self.da_username, - 'da_domain': self.da_domain + 'da_domain': self.da_domain, # Keep for backward compatibility + 'domains': self.get_domains() # Never include passwords or secrets in dict! } diff --git a/app/settings.py b/app/settings.py index 9935b8d..569667f 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, jsonify from flask_login import login_required, current_user -from app.models import db +from app.models import db, UserDomain from app.directadmin_api import DirectAdminAPI import traceback @@ -20,7 +20,8 @@ def get_da_config(): return jsonify({ 'da_server': current_user.da_server or '', 'da_username': current_user.da_username or '', - 'da_domain': current_user.da_domain or '', + 'da_domain': current_user.da_domain or '', # Keep for backward compatibility + 'domains': current_user.get_domains(), 'has_password': bool(current_user.da_password_encrypted), 'theme_preference': current_user.theme_preference or 'light' }) @@ -43,8 +44,8 @@ def update_da_config(): print(f"Received data: {data}") - # Validate required fields - required_fields = ['da_server', 'da_username', 'da_domain'] + # Validate required fields (domain no longer required here) + required_fields = ['da_server', 'da_username'] missing_fields = [field for field in required_fields if not data.get(field, '').strip()] if missing_fields: @@ -65,7 +66,10 @@ def update_da_config(): # Update settings current_user.da_server = server_url.rstrip('/') current_user.da_username = data['da_username'].strip() - current_user.da_domain = data['da_domain'].strip() + + # Keep da_domain for backward compatibility with first domain + if data.get('da_domain'): + current_user.da_domain = data['da_domain'].strip() # Update password if provided if data.get('da_password'): @@ -124,6 +128,128 @@ def test_connection(): print(traceback.format_exc()) return jsonify({'error': 'An internal error has occurred.', 'success': False}), 200 +@settings_bp.route('/api/domains', methods=['GET']) +@login_required +def get_domains(): + """Get all domains for the current user""" + try: + domains = current_user.get_domains() + return jsonify({ + 'success': True, + 'domains': domains + }) + except Exception as e: + print(f"Error getting domains: {str(e)}") + print(traceback.format_exc()) + return jsonify({'error': 'An internal error has occurred.'}), 500 + +@settings_bp.route('/api/domains', methods=['POST']) +@login_required +def add_domain(): + """Add a new domain for the current user""" + try: + data = request.get_json() + if not data or not data.get('domain'): + return jsonify({'error': 'Domain is required'}), 400 + + domain = data['domain'].strip() + if not domain: + return jsonify({'error': 'Domain cannot be empty'}), 400 + + # Basic domain validation + if not '.' in domain or ' ' in domain: + return jsonify({'error': 'Invalid domain format'}), 400 + + success, message = current_user.add_domain(domain) + + if success: + # Update da_domain if this is the first domain (backward compatibility) + if not current_user.da_domain: + current_user.da_domain = domain + + db.session.commit() + return jsonify({ + 'success': True, + 'message': message, + 'domains': current_user.get_domains() + }) + else: + return jsonify({'error': message}), 400 + + except Exception as e: + print(f"Error adding domain: {str(e)}") + print(traceback.format_exc()) + db.session.rollback() + return jsonify({'error': 'An internal error has occurred.'}), 500 + +@settings_bp.route('/api/domains', methods=['DELETE']) +@login_required +def remove_domain(): + """Remove a domain for the current user""" + try: + data = request.get_json() + if not data or not data.get('domain'): + return jsonify({'error': 'Domain is required'}), 400 + + domain = data['domain'].strip() + success, message = current_user.remove_domain(domain) + + if success: + # Update da_domain if we removed the current one + if current_user.da_domain == domain: + first_domain = current_user.get_first_domain() + current_user.da_domain = first_domain + + db.session.commit() + return jsonify({ + 'success': True, + 'message': message, + 'domains': current_user.get_domains() + }) + else: + return jsonify({'error': message}), 400 + + except Exception as e: + print(f"Error removing domain: {str(e)}") + print(traceback.format_exc()) + db.session.rollback() + return jsonify({'error': 'An internal error has occurred.'}), 500 + +@settings_bp.route('/api/domains/reorder', methods=['POST']) +@login_required +def reorder_domains(): + """Reorder domains for the current user""" + try: + data = request.get_json() + if not data or not data.get('domains'): + return jsonify({'error': 'Domains list is required'}), 400 + + domains = data['domains'] + if not isinstance(domains, list): + return jsonify({'error': 'Domains must be a list'}), 400 + + success, message = current_user.reorder_domains(domains) + + if success: + # Update da_domain to the first domain (backward compatibility) + if domains: + current_user.da_domain = domains[0] + + db.session.commit() + return jsonify({ + 'success': True, + 'message': message, + 'domains': current_user.get_domains() + }) + else: + return jsonify({'error': message}), 400 + + except Exception as e: + print(f"Error reordering domains: {str(e)}") + print(traceback.format_exc()) + db.session.rollback() + return jsonify({'error': 'An internal error has occurred.'}), 500 + @settings_bp.route('/api/theme', methods=['POST']) @login_required def update_theme(): diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 099bbc2..b6cbcc5 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -10,6 +10,16 @@

DirectAdmin is not configured. Please configure your settings first.

{% else %} + +
+
+ + +
+
+

Create New Forwarder

@@ -19,7 +29,7 @@

Create New Forwarder

- @{{ current_user.da_domain }} + @loading...
diff --git a/app/templates/settings.html b/app/templates/settings.html index 1df5071..e9ce46b 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -27,80 +27,42 @@

DirectAdmin Configuration

Your DirectAdmin API password (encrypted storage) -
- - - The domain you want to manage forwarders for -
- -
-

- 💡 Tip: You can save settings even if the connection test fails. This is useful when setting up or if the server is temporarily unavailable. -

- - - - - - +

+ 💡 Tip: You can save settings even if the connection test fails. This is useful when setting up or if the server is temporarily unavailable. +

+
+

Domain Management

+

Manage the domains you want to handle email forwarders for. The first domain will be the default.

+ +
+
+ +
+ + +
+ Enter a domain name to manage forwarders for +
+ +
+

Current Domains

+
+

No domains configured. Add a domain above to get started.

+
+
+
+ + +
+

Appearance

diff --git a/static/dashboard.js b/static/dashboard.js index dc2eba1..288d2c9 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -1,6 +1,8 @@ // Dashboard functionality for DirectAdmin Email Forwarder let currentForwarders = []; let emailAccounts = []; +let availableDomains = []; +let selectedDomain = null; // Helper function to validate destinations (including special ones) function isValidDestination(destination) { @@ -14,10 +16,106 @@ function isValidDestination(destination) { return emailRegex.test(destination); } +// Load available domains +async function loadDomains() { + try { + const response = await fetch('/api/domains'); + const data = await response.json(); + + if (response.ok && data.domains) { + availableDomains = data.domains; + + // Set selected domain to first domain if not set + if (!selectedDomain && availableDomains.length > 0) { + selectedDomain = availableDomains[0]; + } + + updateDomainSelector(); + updateDomainSuffix(); + + // Load data for selected domain + if (selectedDomain) { + await loadEmailAccounts(); + await loadForwarders(); + } + } else { + console.error('Failed to load domains:', data.error); + showMessage('Failed to load domains', 'error'); + } + } catch (error) { + console.error('Error loading domains:', error); + showMessage('Error loading domains', 'error'); + } +} + +// Update domain selector dropdown +function updateDomainSelector() { + const select = document.getElementById('domainSelect'); + if (!select) return; + + select.innerHTML = ''; + + if (availableDomains.length === 0) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No domains configured'; + option.disabled = true; + option.selected = true; + select.appendChild(option); + return; + } + + availableDomains.forEach(domain => { + const option = document.createElement('option'); + option.value = domain; + option.textContent = domain; + option.selected = domain === selectedDomain; + select.appendChild(option); + }); +} + +// Update domain suffix in form +function updateDomainSuffix() { + const suffix = document.getElementById('domainSuffix'); + if (suffix) { + suffix.textContent = selectedDomain ? `@${selectedDomain}` : '@no-domain'; + } +} + +// Switch to different domain +async function switchDomain() { + const select = document.getElementById('domainSelect'); + if (!select) return; + + const newDomain = select.value; + if (newDomain === selectedDomain) return; + + selectedDomain = newDomain; + updateDomainSuffix(); + + // Clear current data + currentForwarders = []; + emailAccounts = []; + + // Load new data + if (selectedDomain) { + await loadEmailAccounts(); + await loadForwarders(); + } +} + +// Make switchDomain globally available +window.switchDomain = switchDomain; + // Load email accounts for destination dropdown async function loadEmailAccounts() { + if (!selectedDomain) { + console.log('No domain selected, skipping email accounts load'); + return; + } + try { - const response = await fetch('/api/email-accounts'); + const response = await fetch(`/api/email-accounts?domain=${encodeURIComponent(selectedDomain)}`); const data = await response.json(); if (response.ok && data.accounts) { @@ -25,7 +123,7 @@ async function loadEmailAccounts() { updateDestinationDropdown(); } else { console.error('Failed to load email accounts:', data.error); - showMessage('Failed to load email accounts', 'error'); + showMessage(`Failed to load email accounts for ${selectedDomain}`, 'error'); } } catch (error) { console.error('Error loading email accounts:', error); @@ -92,8 +190,13 @@ async function loadForwarders() { const tbody = document.querySelector('#forwardersTable tbody'); if (!tbody) return; + if (!selectedDomain) { + tbody.innerHTML = 'No domain selected'; + return; + } + try { - const response = await fetch('/api/forwarders'); + const response = await fetch(`/api/forwarders?domain=${encodeURIComponent(selectedDomain)}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); @@ -114,7 +217,7 @@ async function loadForwarders() { } catch (error) { console.error('Error loading forwarders:', error); - tbody.innerHTML = 'Failed to load forwarders. Please check your DirectAdmin settings.'; + tbody.innerHTML = 'Failed to load forwarders for ' + selectedDomain + '. Please check your DirectAdmin settings.'; } } @@ -209,7 +312,8 @@ async function createForwarder(event) { }, body: JSON.stringify({ address: addressInput.value.trim(), - destination: destination + destination: destination, + domain: selectedDomain }) }); @@ -261,7 +365,8 @@ async function deleteForwarder(address) { 'Content-Type': 'application/json', }, body: JSON.stringify({ - address: address + address: address, + domain: selectedDomain }) }); @@ -327,11 +432,14 @@ document.addEventListener('DOMContentLoaded', function() { console.log('Initializing dashboard...'); // Load initial data - loadEmailAccounts(); - loadForwarders(); + loadDomains(); // Set up auto-refresh every 60 seconds - setInterval(loadForwarders, 60000); + setInterval(() => { + if (selectedDomain) { + loadForwarders(); + } + }, 60000); // Set up form handler const form = document.getElementById('createForwarderForm'); diff --git a/static/settings.js b/static/settings.js index 181bb6a..0243402 100644 --- a/static/settings.js +++ b/static/settings.js @@ -1,18 +1,14 @@ -// Add this to the top of settings.js to debug console.log('Settings page loaded'); -// Check if the GET request works -fetch('/settings/api/da-config') - .then(response => { - console.log('GET /settings/api/da-config - Status:', response.status); - if (response.status === 405) { - console.error('GET method not allowed!'); - } - }); +let currentDomains = []; - -// Load current settings on page load +// Load current settings and domains on page load document.addEventListener('DOMContentLoaded', async () => { + await loadSettings(); + await loadDomains(); +}); + +async function loadSettings() { try { const response = await fetch('/settings/api/da-config'); if (!response.ok) { @@ -24,7 +20,6 @@ document.addEventListener('DOMContentLoaded', async () => { if (config.da_server) document.getElementById('da_server').value = config.da_server; if (config.da_username) document.getElementById('da_username').value = config.da_username; - if (config.da_domain) document.getElementById('da_domain').value = config.da_domain; if (config.has_password) { document.getElementById('da_password').placeholder = 'Password is set (leave empty to keep current)'; @@ -40,9 +35,26 @@ document.addEventListener('DOMContentLoaded', async () => { } catch (error) { console.error('Error loading settings:', error); } -}); +} -// Replace the form submission handler with this debug version +async function loadDomains() { + try { + const response = await fetch('/settings/api/domains'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + if (result.success) { + currentDomains = result.domains; + renderDomains(); + } + } catch (error) { + console.error('Error loading domains:', error); + } +} + +// Form submission handler for DirectAdmin config document.getElementById('daConfigForm').addEventListener('submit', async (e) => { e.preventDefault(); @@ -54,67 +66,32 @@ document.getElementById('daConfigForm').addEventListener('submit', async (e) => const formData = { da_server: document.getElementById('da_server').value.trim(), da_username: document.getElementById('da_username').value.trim(), - da_password: document.getElementById('da_password').value, - da_domain: document.getElementById('da_domain').value.trim() + da_password: document.getElementById('da_password').value }; - console.log('=== SAVE DEBUG ==='); - console.log('Form data:', formData); - console.log('JSON stringified:', JSON.stringify(formData)); - try { const response = await fetch('/settings/api/da-config', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json' // Added this + 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(formData) }); - // Get raw response first - const contentType = response.headers.get('content-type'); - console.log('Response Content-Type:', contentType); - console.log('Response Status:', response.status); - - const responseText = await response.text(); - console.log('Raw response (first 200 chars):', responseText.substring(0, 200)); - - let result; - try { - result = JSON.parse(responseText); - console.log('Parsed response:', result); - } catch (parseError) { - console.error('Failed to parse JSON:', parseError); - if (responseText.includes(' { - if (confirm('Settings saved! Go to dashboard now?')) { - window.location.href = '/dashboard'; - } - }, 1000); } else { showMessage('error', result.error || 'Failed to save settings'); if (result.missing_fields) { result.missing_fields.forEach(field => { - document.getElementById(field).classList.add('error'); + const element = document.getElementById(field); + if (element) element.classList.add('error'); }); } } @@ -124,7 +101,154 @@ document.getElementById('daConfigForm').addEventListener('submit', async (e) => } finally { saveButton.textContent = originalText; saveButton.disabled = false; - console.log('=== END SAVE DEBUG ==='); + } +}); + +// Domain management functions +function renderDomains() { + const container = document.getElementById('domains-container'); + + if (currentDomains.length === 0) { + container.innerHTML = '

No domains configured. Add a domain above to get started.

'; + return; + } + + const domainsList = currentDomains.map((domain, index) => ` +
+
+ ${domain} + ${index === 0 ? 'Default' : ''} +
+
+ ${index > 0 ? `` : ''} + ${index < currentDomains.length - 1 ? `` : ''} + +
+
+ `).join(''); + + container.innerHTML = `
${domainsList}
`; +} + +async function addDomain() { + const input = document.getElementById('new_domain'); + const domain = input.value.trim(); + + if (!domain) { + showMessage('error', 'Please enter a domain name'); + return; + } + + if (!domain.includes('.') || domain.includes(' ')) { + showMessage('error', 'Please enter a valid domain name'); + return; + } + + try { + const response = await fetch('/settings/api/domains', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ domain }) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showMessage('success', result.message); + currentDomains = result.domains; + renderDomains(); + input.value = ''; + } else { + showMessage('error', result.error || 'Failed to add domain'); + } + } catch (error) { + console.error('Error adding domain:', error); + showMessage('error', 'Error adding domain: ' + error.message); + } +} + +async function removeDomain(domain) { + if (!confirm(`Are you sure you want to remove the domain "${domain}"?`)) { + return; + } + + try { + const response = await fetch('/settings/api/domains', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ domain }) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showMessage('success', result.message); + currentDomains = result.domains; + renderDomains(); + } else { + showMessage('error', result.error || 'Failed to remove domain'); + } + } catch (error) { + console.error('Error removing domain:', error); + showMessage('error', 'Error removing domain: ' + error.message); + } +} + +async function moveDomainUp(domain) { + const index = currentDomains.indexOf(domain); + if (index <= 0) return; + + const newOrder = [...currentDomains]; + [newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]]; + + await reorderDomains(newOrder); +} + +async function moveDomainDown(domain) { + const index = currentDomains.indexOf(domain); + if (index < 0 || index >= currentDomains.length - 1) return; + + const newOrder = [...currentDomains]; + [newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]]; + + await reorderDomains(newOrder); +} + +async function reorderDomains(newOrder) { + try { + const response = await fetch('/settings/api/domains/reorder', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ domains: newOrder }) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + currentDomains = result.domains; + renderDomains(); + } else { + showMessage('error', result.error || 'Failed to reorder domains'); + } + } catch (error) { + console.error('Error reordering domains:', error); + showMessage('error', 'Error reordering domains: ' + error.message); + } +} + +// Allow Enter key to add domain +document.getElementById('new_domain').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + addDomain(); } }); diff --git a/static/style.css b/static/style.css index d58893e..3d2a82c 100644 --- a/static/style.css +++ b/static/style.css @@ -1160,3 +1160,134 @@ input:checked + .theme-slider:before { display: inline-block; margin-top: 0.5rem; } + +/* Domain Management Styles */ +.domain-management { + margin-top: 1rem; +} + +.domain-input-group { + display: flex; + gap: 0.5rem; + align-items: flex-end; +} + +.domain-input-group input { + flex: 1; +} + +.domain-input-group button { + height: fit-content; + padding: 0.5rem 1rem; +} + +.domains-list { + margin-top: 2rem; +} + +.domains-list h4 { + margin-bottom: 1rem; + color: var(--text-color); +} + +.no-domains { + color: var(--text-muted); + font-style: italic; + text-align: center; + padding: 2rem; + background-color: var(--background-color); + border-radius: 4px; +} + +.domains-list-content { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.domain-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 4px; + transition: background-color 0.2s ease; +} + +.domain-item:hover { + background-color: var(--background-color); +} + +.domain-info { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.domain-name { + font-weight: 500; + color: var(--text-color); +} + +.domain-badge { + background-color: var(--primary-color); + color: white; + padding: 0.2rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +.domain-actions { + display: flex; + gap: 0.25rem; +} + +.btn-small { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + background-color: var(--secondary-color); + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + transition: background-color 0.2s ease; + min-width: 24px; +} + +.btn-small:hover { + background-color: var(--secondary-hover); +} + +.btn-small.btn-danger { + background-color: #dc3545; +} + +.btn-small.btn-danger:hover { + background-color: #c82333; +} + +/* Domain selector dropdown styles (for dashboard) */ +.domain-selector { + margin-bottom: 1rem; +} + +.domain-selector label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-color); +} + +.domain-selector select { + width: 100%; + max-width: 300px; + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--surface-color); + color: var(--text-color); + font-size: 1rem; +} From ae691c7d6365d33315a716f11d6db7a78a441fed Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:03:18 +0200 Subject: [PATCH 03/11] Fixes --- app/main.py | 178 ++++++++++++++++++++++++++++++++++++++++++++++++-- app/models.py | 130 ++++++++++++++++++++++++------------ 2 files changed, 261 insertions(+), 47 deletions(-) diff --git a/app/main.py b/app/main.py index f28b85f..03846d9 100644 --- a/app/main.py +++ b/app/main.py @@ -48,10 +48,15 @@ def index(): @login_required def dashboard(): """Main dashboard page""" - if not current_user.has_da_config(): - # Redirect to settings if DirectAdmin not configured + try: + if not current_user.has_da_config(): + # Redirect to settings if DirectAdmin not configured + return redirect(url_for('settings.index')) + return render_template('dashboard.html') + except Exception as e: + print(f"Error in dashboard route: {e}") + # If there's an error (likely due to missing table), redirect to settings return redirect(url_for('settings.index')) - return render_template('dashboard.html') # ===== API Routes ===== @@ -61,6 +66,7 @@ def get_user_domains(): """Get all domains for the current user""" try: domains = current_user.get_domains() + return jsonify({ 'success': True, 'domains': domains @@ -68,11 +74,55 @@ def get_user_domains(): except Exception as e: print(f"Error in /api/domains: {str(e)}") traceback.print_exc() + return jsonify({ 'error': 'Failed to fetch domains', 'domains': [] }), 500 + @app.route('/api/migration-status', methods=['GET']) + @login_required + def get_migration_status(): + """Get migration status for debugging""" + try: + from app.models import UserDomain + + status = { + 'user_id': current_user.id, + 'username': current_user.username, + 'legacy_domain': current_user.da_domain, + 'has_da_config': current_user.has_da_config() + } + + try: + # Check if UserDomain table exists + domain_count = UserDomain.query.filter_by(user_id=current_user.id).count() + user_domains = [d.domain for d in UserDomain.query.filter_by(user_id=current_user.id).all()] + + status.update({ + 'table_exists': True, + 'domain_count': domain_count, + 'domains': user_domains, + 'migration_needed': current_user.da_domain and domain_count == 0 + }) + except Exception as e: + status.update({ + 'table_exists': False, + 'table_error': str(e), + 'migration_needed': True + }) + + return jsonify({ + 'success': True, + 'status': status + }) + + except Exception as e: + return jsonify({ + 'error': f'Migration status check failed: {str(e)}', + 'success': False + }), 500 + @app.route('/api/email-accounts', methods=['GET']) @login_required def get_email_accounts(): @@ -359,7 +409,127 @@ def handle_exception(error): # ===== Database Initialization ===== - # Ensure DB initialization only once (important with multi-worker if --preload not used)\n if not app.config.get('_DB_INITIALIZED', False):\n with app.app_context():\n print(f\"Initializing database at URI: {app.config['SQLALCHEMY_DATABASE_URI']}\")\n db.create_all()\n\n # Migrate existing users from single domain to multi-domain\n print(\"Checking for users to migrate to multi-domain...\")\n users_to_migrate = User.query.filter(\n User.da_domain.isnot(None),\n ~User.domains.any()\n ).all()\n \n for user in users_to_migrate:\n print(f\"Migrating user {user.username} with domain {user.da_domain}\")\n success, message = user.add_domain(user.da_domain)\n if success:\n print(f\" ✓ Migrated {user.username}: {message}\")\n else:\n print(f\" ✗ Failed to migrate {user.username}: {message}\")\n \n if users_to_migrate:\n try:\n db.session.commit()\n print(f\"Successfully migrated {len(users_to_migrate)} users to multi-domain.\")\n except Exception as e:\n print(f\"Error during migration: {e}\")\n db.session.rollback()\n\n # Create default admin user only if no administrators exist\n admin_count = User.query.filter_by(is_admin=True).count()\n if admin_count == 0:\n # No administrators exist, create default admin user\n admin_user = User(username='admin', is_admin=True)\n admin_user.set_password('changeme') # Default password\n db.session.add(admin_user)\n try:\n db.session.commit()\n print(\"=\" * 50)\n print(\"Default admin user created!\")\n print(\"Username: admin\")\n print(\"Password: changeme\")\n print(\"PLEASE CHANGE THIS PASSWORD IMMEDIATELY!\")\n print(\"=\" * 50)\n except Exception as e:\n print(f\"Error creating admin user: {e}\")\n db.session.rollback()\n else:\n print(f\"Found {admin_count} administrator(s) - skipping default admin creation\")\n\n app.config['_DB_INITIALIZED'] = True + # Ensure DB initialization only once (important with multi-worker if --preload not used) + if not app.config.get('_DB_INITIALIZED', False): + with app.app_context(): + print(f"Initializing database at URI: {app.config['SQLALCHEMY_DATABASE_URI']}") + + # Import models to ensure they're registered + from app.models import User, UserDomain + + # Check if we need to handle schema migration + needs_migration = False + try: + # Test if UserDomain table exists by trying a simple query + db.session.execute(db.text("SELECT COUNT(*) FROM user_domain")).scalar() + print("UserDomain table exists") + except Exception as e: + print(f"UserDomain table doesn't exist: {e}") + needs_migration = True + + # Always call create_all() to ensure all tables exist + db.create_all() + print("Database tables created/updated") + + # If we needed migration, migrate existing users + if needs_migration: + print("Performing automatic migration to multi-domain...") + + # Find all users with da_domain set + try: + users_with_domains = User.query.filter(User.da_domain.isnot(None)).all() + print(f"Found {len(users_with_domains)} users with domains to migrate") + + for user in users_with_domains: + print(f"Migrating user {user.username} with domain {user.da_domain}") + try: + # Create UserDomain entry directly to avoid circular dependency + existing_domain = UserDomain.query.filter_by( + user_id=user.id, + domain=user.da_domain + ).first() + + if not existing_domain: + user_domain = UserDomain( + user_id=user.id, + domain=user.da_domain, + order_index=0 + ) + db.session.add(user_domain) + print(f" ✓ Created domain entry for {user.username}: {user.da_domain}") + else: + print(f" - Domain already exists for {user.username}: {user.da_domain}") + + except Exception as e: + print(f" ✗ Error migrating {user.username}: {e}") + + # Commit migration changes + try: + db.session.commit() + print(f"✓ Successfully migrated {len(users_with_domains)} users to multi-domain.") + except Exception as e: + print(f"✗ Error during migration commit: {e}") + db.session.rollback() + + except Exception as e: + print(f"Error during user migration: {e}") + db.session.rollback() + else: + # Check for users that have da_domain but no UserDomain entries + try: + users_to_migrate = User.query.filter( + User.da_domain.isnot(None), + ~User.domains.any() + ).all() + + if users_to_migrate: + print(f"Found {len(users_to_migrate)} users needing domain migration...") + + for user in users_to_migrate: + print(f"Migrating user {user.username} with domain {user.da_domain}") + try: + user_domain = UserDomain( + user_id=user.id, + domain=user.da_domain, + order_index=0 + ) + db.session.add(user_domain) + print(f" ✓ Created domain entry for {user.username}: {user.da_domain}") + except Exception as e: + print(f" ✗ Error migrating {user.username}: {e}") + + try: + db.session.commit() + print(f"✓ Successfully migrated {len(users_to_migrate)} users to multi-domain.") + except Exception as e: + print(f"✗ Error during migration commit: {e}") + db.session.rollback() + + except Exception as e: + print(f"Error checking for migration: {e}") + + # Create default admin user only if no administrators exist + admin_count = User.query.filter_by(is_admin=True).count() + if admin_count == 0: + # No administrators exist, create default admin user + admin_user = User(username='admin', is_admin=True) + admin_user.set_password('changeme') # Default password + db.session.add(admin_user) + try: + db.session.commit() + print("=" * 50) + print("Default admin user created!") + print("Username: admin") + print("Password: changeme") + print("PLEASE CHANGE THIS PASSWORD IMMEDIATELY!") + print("=" * 50) + except Exception as e: + print(f"Error creating admin user: {e}") + db.session.rollback() + else: + print(f"Found {admin_count} administrator(s) - skipping default admin creation") + + app.config['_DB_INITIALIZED'] = True # ===== Additional App Configuration ===== diff --git a/app/models.py b/app/models.py index f2e609d..ae0e47b 100644 --- a/app/models.py +++ b/app/models.py @@ -114,70 +114,114 @@ def get_da_password(self): def has_da_config(self): """Check if user has configured DirectAdmin settings""" + try: + # Check if we have domains configured + domains = self.get_domains() + has_domains = len(domains) > 0 + except Exception: + # If domains relationship fails, fall back to legacy da_domain + has_domains = bool(self.da_domain) + return all([ self.da_server, self.da_username, self.da_password_encrypted - ]) and len(self.get_domains()) > 0 + ]) and has_domains # ===== Domain Management ===== def get_domains(self): """Get all domains for this user in order""" - return [d.domain for d in self.domains] + try: + # Try to get domains from the new multi-domain table + domains = [d.domain for d in self.domains] + + # If no domains in new table but legacy domain exists, include it + if not domains and self.da_domain: + return [self.da_domain] + + return domains + except Exception as e: + # If domains relationship fails (table doesn't exist or other error), + # fall back to legacy da_domain if available + if self.da_domain: + return [self.da_domain] + return [] def get_first_domain(self): """Get the first domain (default) for this user""" - domains = self.get_domains() - return domains[0] if domains else None + try: + domains = self.get_domains() + if domains: + return domains[0] + # Fall back to legacy da_domain if no domains in new table + return self.da_domain + except Exception: + # If anything fails, return legacy da_domain + return self.da_domain def add_domain(self, domain): """Add a new domain for this user""" - # Check if domain already exists - existing = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() - if existing: - return False, "Domain already exists" - - # Get next order index - max_order = db.session.query(db.func.max(UserDomain.order_index)).filter_by(user_id=self.id).scalar() - next_order = (max_order or -1) + 1 - - # Create new domain - user_domain = UserDomain( - user_id=self.id, - domain=domain, - order_index=next_order - ) - - db.session.add(user_domain) - return True, "Domain added successfully" + try: + # Check if domain already exists + existing = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() + if existing: + return False, "Domain already exists" + + # Get next order index + max_order = db.session.query(db.func.max(UserDomain.order_index)).filter_by(user_id=self.id).scalar() + next_order = (max_order or -1) + 1 + + # Create new domain + user_domain = UserDomain( + user_id=self.id, + domain=domain, + order_index=next_order + ) + + db.session.add(user_domain) + return True, "Domain added successfully" + + except Exception as e: + print(f"Error adding domain {domain} for user {self.username}: {e}") + return False, f"Failed to add domain: {str(e)}" def remove_domain(self, domain): """Remove a domain for this user""" - user_domain = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() - if not user_domain: - return False, "Domain not found" - - db.session.delete(user_domain) - - # Reorder remaining domains - remaining_domains = UserDomain.query.filter_by(user_id=self.id).filter( - UserDomain.order_index > user_domain.order_index - ).all() - - for domain_obj in remaining_domains: - domain_obj.order_index -= 1 - - return True, "Domain removed successfully" + try: + user_domain = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() + if not user_domain: + return False, "Domain not found" + + db.session.delete(user_domain) + + # Reorder remaining domains + remaining_domains = UserDomain.query.filter_by(user_id=self.id).filter( + UserDomain.order_index > user_domain.order_index + ).all() + + for domain_obj in remaining_domains: + domain_obj.order_index -= 1 + + return True, "Domain removed successfully" + + except Exception as e: + print(f"Error removing domain {domain} for user {self.username}: {e}") + return False, f"Failed to remove domain: {str(e)}" def reorder_domains(self, domain_list): """Reorder domains based on provided list""" - for i, domain in enumerate(domain_list): - user_domain = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() - if user_domain: - user_domain.order_index = i - - return True, "Domains reordered successfully" + try: + for i, domain in enumerate(domain_list): + user_domain = UserDomain.query.filter_by(user_id=self.id, domain=domain).first() + if user_domain: + user_domain.order_index = i + + return True, "Domains reordered successfully" + + except Exception as e: + print(f"Error reordering domains for user {self.username}: {e}") + return False, f"Failed to reorder domains: {str(e)}" # ===== TOTP/2FA Management ===== From f50006ad354cc799ab626c15aaee474d6eaa3f2c Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:20:10 +0200 Subject: [PATCH 04/11] Fixes --- app/directadmin_api.py | 137 +++++++++++++++++++++++++++++++++++------ app/main.py | 16 +++++ app/settings.py | 16 ++++- static/dashboard.js | 18 +++++- static/settings.js | 36 ++++++++--- 5 files changed, 193 insertions(+), 30 deletions(-) diff --git a/app/directadmin_api.py b/app/directadmin_api.py index 5e61958..c052627 100644 --- a/app/directadmin_api.py +++ b/app/directadmin_api.py @@ -64,6 +64,17 @@ def _make_request(self, endpoint, data=None, method='POST'): text = response.text.strip() print(f"Raw response: {text[:500]}...") # First 500 chars for debugging + # Check if we got HTML instead of API data + if text.startswith(' 3 else ''}" else: return True, "Successfully connected to DirectAdmin." else: return True, "Successfully connected to DirectAdmin." + else: + print("CMD_API_SHOW_DOMAINS returned None (likely HTML response)") # If that fails, try a simpler endpoint + print("Trying CMD_API_SHOW_USER_CONFIG...") endpoint = '/CMD_API_SHOW_USER_CONFIG' response = self._make_request(endpoint, method='GET') - if response: + if response is not None: return True, "Successfully connected to DirectAdmin." + else: + print("CMD_API_SHOW_USER_CONFIG also returned None") - return False, "Failed to connect. Please check your credentials." + return False, "Failed to connect. Server returned HTML instead of API data - please check your DirectAdmin URL, credentials, and API access." except Exception as e: import traceback - print(f"Connection error: {str(e)}") + error_msg = str(e) + print(f"Connection test exception: {error_msg}") traceback.print_exc() - return False, "Connection error: Unable to connect to DirectAdmin." + + # Provide more specific error messages + if 'timeout' in error_msg.lower(): + return False, "Connection timed out. Please check your DirectAdmin server URL and network connection." + elif 'connection' in error_msg.lower(): + return False, "Unable to connect to DirectAdmin server. Please verify the server URL and credentials." + elif 'ssl' in error_msg.lower() or 'certificate' in error_msg.lower(): + return False, "SSL certificate error. Try using HTTP instead of HTTPS." + else: + return False, f"Connection error: {error_msg}" + + def validate_domain_access(self): + """Check if the current domain is accessible via the API""" + try: + print(f"\n=== Validating Domain Access for {self.domain} ===") + + # Try to get domain list to verify access + endpoint = '/CMD_API_SHOW_DOMAINS' + response = self._make_request(endpoint, method='GET') + + if response and isinstance(response, dict): + domain_list = [] + for key, value in response.items(): + if 'domain' in key.lower() or key.startswith('list'): + domain_list.append(value) + elif '.' in key and not key.startswith('<'): # Might be domain name as key, but not HTML + domain_list.append(key) + + if self.domain in domain_list: + print(f"✓ Domain {self.domain} found in account") + return True, f"Domain {self.domain} is accessible" + else: + print(f"✗ Domain {self.domain} not found in account") + print(f"Available domains: {domain_list}") + return False, f"Domain {self.domain} not found in DirectAdmin account" + + print("Could not verify domain access - no domain list returned") + return False, "Unable to verify domain access" + + except Exception as e: + print(f"Error validating domain access: {e}") + return False, f"Error validating domain: {str(e)}" def get_email_accounts(self): """Get all email accounts for the domain""" try: print(f"\n=== Getting Email Accounts for {self.domain} ===") - # Try multiple endpoints + # Try API endpoints only endpoints = [ ('/CMD_API_POP', {'action': 'list', 'domain': self.domain}), ('/CMD_API_POP', {'domain': self.domain}), @@ -202,7 +268,11 @@ def get_email_accounts(self): break if response is None: - print("No response from any email endpoint") + print("No valid response from any email accounts endpoint") + print("This could mean:") + print("- The domain doesn't exist in DirectAdmin") + print("- API user doesn't have permission for this domain") + print("- DirectAdmin API is not properly configured") return [] print(f"Raw response type: {type(response)}") @@ -267,15 +337,29 @@ def get_email_accounts(self): elif line and not line.startswith('error'): accounts.append(f"{line}@{self.domain}") - # Ensure all accounts have domain part + # Ensure all accounts have domain part and filter out invalid entries processed_accounts = [] for account in accounts: if account: # Skip empty strings + # Skip entries that look like HTML + if account.startswith('<') or '"' in account or account.startswith(':root'): + print(f"Skipping invalid account that looks like HTML: {account}") + continue + + # Validate email format + import re if '@' not in account: - # Add domain if missing - processed_accounts.append(f"{account}@{self.domain}") + # Validate username part before adding domain + if re.match(r'^[a-zA-Z0-9._-]+$', account): + processed_accounts.append(f"{account}@{self.domain}") + else: + print(f"Skipping invalid username: {account}") else: - processed_accounts.append(account) + # Validate full email + if re.match(r'^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', account): + processed_accounts.append(account) + else: + print(f"Skipping invalid email: {account}") # Remove duplicates and filter out API user processed_accounts = list(set(processed_accounts)) @@ -298,11 +382,10 @@ def get_forwarders(self): try: print(f"\n=== Getting Forwarders for {self.domain} ===") - # Try multiple endpoint variations + # Try API endpoints only (avoid web interface endpoints) endpoints = [ ('/CMD_API_EMAIL_FORWARDERS', {'domain': self.domain, 'action': 'list'}), ('/CMD_API_EMAIL_FORWARDERS', {'domain': self.domain}), - ('/CMD_EMAIL_FORWARDERS', {'domain': self.domain}), ] response = None @@ -312,17 +395,21 @@ def get_forwarders(self): # Try GET first response = self._make_request(endpoint, params, method='GET') if response: - print(f"Got response with GET") + print(f"Got valid response with GET") break # Try POST response = self._make_request(endpoint, params, method='POST') if response: - print(f"Got response with POST") + print(f"Got valid response with POST") break if response is None: - print("ERROR: No response from any forwarders endpoint!") + print("ERROR: No valid response from any API endpoint!") + print("This could mean:") + print("- The domain doesn't exist in DirectAdmin") + print("- API user doesn't have permission for this domain") + print("- DirectAdmin API is not properly configured") return [] print(f"\n=== FORWARDERS RAW RESPONSE ===") @@ -377,6 +464,18 @@ def get_forwarders(self): if key.startswith('error') or key == 'domain': continue + # Skip invalid keys that look like HTML + if key.startswith('<') or '"' in key or key.startswith(':root'): + print(f"Skipping invalid key that looks like HTML: {key}") + continue + + # Validate that the key looks like a valid email username + # Allow alphanumeric, dots, hyphens, underscores + import re + if not re.match(r'^[a-zA-Z0-9._-]+$', key): + print(f"Skipping invalid username: {key}") + continue + # IMPORTANT: Accept ALL non-empty values as valid destinations if value: # Key is the username, value is the destination diff --git a/app/main.py b/app/main.py index 03846d9..646b3dd 100644 --- a/app/main.py +++ b/app/main.py @@ -161,6 +161,14 @@ def get_email_accounts(): domain ) + # Validate domain access first + domain_valid, domain_message = api.validate_domain_access() + if not domain_valid: + return jsonify({ + 'error': f'Domain access validation failed: {domain_message}', + 'accounts': [] + }), 403 + # Get email accounts accounts = api.get_email_accounts() @@ -222,6 +230,14 @@ def get_forwarders(): domain ) + # Validate domain access first + domain_valid, domain_message = api.validate_domain_access() + if not domain_valid: + return jsonify({ + 'error': f'Domain access validation failed: {domain_message}', + 'forwarders': [] + }), 403 + # Get forwarders forwarders = api.get_forwarders() diff --git a/app/settings.py b/app/settings.py index 569667f..538f1a7 100644 --- a/app/settings.py +++ b/app/settings.py @@ -124,9 +124,21 @@ def test_connection(): }) except Exception as e: - print(f"Test connection error: {str(e)}") + error_msg = str(e) + print(f"Test connection error: {error_msg}") print(traceback.format_exc()) - return jsonify({'error': 'An internal error has occurred.', 'success': False}), 200 + + # Provide more specific error messages + if 'timeout' in error_msg.lower(): + error_msg = 'Connection timed out. Please check your DirectAdmin server URL and network connection.' + elif 'connection' in error_msg.lower(): + error_msg = 'Unable to connect to DirectAdmin server. Please verify the server URL is correct.' + elif 'ssl' in error_msg.lower() or 'certificate' in error_msg.lower(): + error_msg = 'SSL certificate error. Try using HTTP instead of HTTPS, or check your certificate configuration.' + else: + error_msg = f'Connection test failed: {error_msg}' + + return jsonify({'error': error_msg, 'success': False}), 200 @settings_bp.route('/api/domains', methods=['GET']) @login_required diff --git a/static/dashboard.js b/static/dashboard.js index 288d2c9..1eabe68 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -123,10 +123,19 @@ async function loadEmailAccounts() { updateDestinationDropdown(); } else { console.error('Failed to load email accounts:', data.error); - showMessage(`Failed to load email accounts for ${selectedDomain}`, 'error'); + if (response.status === 403) { + showMessage(`Domain access denied: ${selectedDomain} may not be configured in your DirectAdmin account`, 'error'); + } else { + showMessage(`Failed to load email accounts for ${selectedDomain}: ${data.error || 'Unknown error'}`, 'error'); + } + + // Clear dropdown on error + updateDestinationDropdown(); } } catch (error) { console.error('Error loading email accounts:', error); + showMessage(`Error loading email accounts for ${selectedDomain}`, 'error'); + updateDestinationDropdown(); } } @@ -217,7 +226,12 @@ async function loadForwarders() { } catch (error) { console.error('Error loading forwarders:', error); - tbody.innerHTML = 'Failed to load forwarders for ' + selectedDomain + '. Please check your DirectAdmin settings.'; + + if (error.response && error.response.status === 403) { + tbody.innerHTML = 'Domain access denied: ' + selectedDomain + ' may not be configured in your DirectAdmin account.'; + } else { + tbody.innerHTML = 'Failed to load forwarders for ' + selectedDomain + '. Please check your DirectAdmin settings.'; + } } } diff --git a/static/settings.js b/static/settings.js index 0243402..8fb1580 100644 --- a/static/settings.js +++ b/static/settings.js @@ -255,8 +255,16 @@ document.getElementById('new_domain').addEventListener('keypress', (e) => { // Test connection function - COMPLETELY SEPARATE async function testConnection() { + console.log('testConnection called'); const testButton = event.target; const originalText = testButton.textContent; + + // Ensure we always reset the button state + const resetButton = () => { + testButton.textContent = originalText; + testButton.disabled = false; + }; + testButton.textContent = 'Testing...'; testButton.disabled = true; @@ -269,37 +277,51 @@ async function testConnection() { if (!formData.da_server || !formData.da_username) { showMessage('warning', 'Please enter server URL and username to test'); - testButton.textContent = originalText; - testButton.disabled = false; + resetButton(); return; } console.log('Testing connection to:', formData.da_server); try { + // Add timeout to prevent hanging + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + const response = await fetch('/settings/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'same-origin', - body: JSON.stringify(formData) + body: JSON.stringify(formData), + signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const result = await response.json(); console.log('Test response:', result); if (result.success) { showMessage('success', '✓ ' + result.message); } else { - showMessage('warning', '✗ Connection failed: ' + (result.error || 'Unknown error') + '\nYou can still save these settings.'); + showMessage('warning', '✗ Connection failed: ' + (result.error || result.message || 'Unknown error') + '\nYou can still save these settings.'); } } catch (error) { console.error('Error testing connection:', error); - showMessage('error', '✗ Test error: ' + error.message + '\nYou can still save these settings.'); + if (error.name === 'AbortError') { + showMessage('error', '✗ Connection test timed out after 30 seconds. Please check your DirectAdmin server URL and network connection.'); + } else { + showMessage('error', '✗ Test error: ' + error.message + '\nYou can still save these settings.'); + } } finally { - testButton.textContent = originalText; - testButton.disabled = false; + resetButton(); + console.log('testConnection completed, button reset'); } } From 95c3cd8e88834ba048cfd2c157970ac01b4c6c67 Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:26:47 +0200 Subject: [PATCH 05/11] More fixes --- app/directadmin_api.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/directadmin_api.py b/app/directadmin_api.py index c052627..ebac3ac 100644 --- a/app/directadmin_api.py +++ b/app/directadmin_api.py @@ -169,8 +169,9 @@ def test_connection(self): domain_list = [] for key, value in response.items(): if 'domain' in key.lower() or key.startswith('list'): + # Handle case where value is a list (like list[] parameters) if isinstance(value, list): - domain_list.extend(value) + domain_list.extend(value) # Use extend instead of append to flatten else: domain_list.append(value) elif '.' in key and not key.startswith('<'): # Might be domain name as key, but not HTML @@ -229,10 +230,16 @@ def validate_domain_access(self): domain_list = [] for key, value in response.items(): if 'domain' in key.lower() or key.startswith('list'): - domain_list.append(value) + # Handle case where value is a list (like list[] parameters) + if isinstance(value, list): + domain_list.extend(value) # Use extend instead of append to flatten + else: + domain_list.append(value) elif '.' in key and not key.startswith('<'): # Might be domain name as key, but not HTML domain_list.append(key) + print(f"Parsed domain list: {domain_list}") + if self.domain in domain_list: print(f"✓ Domain {self.domain} found in account") return True, f"Domain {self.domain} is accessible" From 1ed3f71999e692dd6dda99cb3c5f0a9821aaad63 Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:47:37 +0200 Subject: [PATCH 06/11] Fixes --- app/directadmin_api.py | 25 ++++++++++++++++++++++++- app/settings.py | 22 +++++++++++++++++----- static/settings.js | 5 ++++- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/app/directadmin_api.py b/app/directadmin_api.py index ebac3ac..340bbf9 100644 --- a/app/directadmin_api.py +++ b/app/directadmin_api.py @@ -25,6 +25,8 @@ def _make_request(self, endpoint, data=None, method='POST'): if data: print(f"Request data: {data}") + print(f"Starting HTTP request with 10 second timeout...") + # Common headers headers = { 'User-Agent': 'DirectAdmin Email Forwarder' @@ -50,6 +52,7 @@ def _make_request(self, endpoint, data=None, method='POST'): headers=headers ) + print(f"HTTP request completed successfully!") print(f"Response status: {response.status_code}") print(f"Response headers: {dict(response.headers)}") @@ -157,8 +160,28 @@ def test_connection(self): print(f"Username: {self.username}") print(f"Domain: {self.domain}") - # Try CMD_API_SHOW_DOMAINS first + # First try a simple HTTP request test + print(f"Testing basic HTTP connectivity...") + import requests + test_url = f"{self.server}/CMD_API_SHOW_DOMAINS" + + try: + basic_response = requests.get( + test_url, + auth=(self.username, self.password), + verify=False, + timeout=5 # Shorter timeout for basic test + ) + print(f"Basic HTTP test: status={basic_response.status_code}") + if basic_response.status_code != 200: + return False, f"HTTP request failed with status {basic_response.status_code}" + except Exception as e: + print(f"Basic HTTP test failed: {e}") + return False, f"Basic connectivity test failed: {str(e)}" + + # Try CMD_API_SHOW_DOMAINS with our parser endpoint = '/CMD_API_SHOW_DOMAINS' + print(f"Making test request to: {self.server}{endpoint}") response = self._make_request(endpoint, method='GET') if response is not None: diff --git a/app/settings.py b/app/settings.py index 538f1a7..77d7dd3 100644 --- a/app/settings.py +++ b/app/settings.py @@ -100,28 +100,40 @@ def update_da_config(): def test_connection(): """Test DirectAdmin connection""" try: + print(f"\n=== Test Connection Request Received ===") data = request.get_json() + print(f"Request data: {data}") # Use provided or saved credentials server = data.get('da_server') or current_user.da_server username = data.get('da_username') or current_user.da_username password = data.get('da_password') or current_user.get_da_password() + domain = data.get('da_domain') or current_user.da_domain + + print(f"Test connection with server: {server}, username: {username}, domain: {domain}") if not all([server, username, password]): - return jsonify({'error': 'Missing credentials'}), 400 + print(f"Missing credentials - server: {bool(server)}, username: {bool(username)}, password: {bool(password)}") + return jsonify({'error': 'Missing credentials', 'success': False}), 200 # Ensure proper URL format if not server.startswith(('http://', 'https://')): server = 'https://' + server - # Test connection - api = DirectAdminAPI(server, username, password) + # Test connection with domain if available + print(f"Creating DirectAdminAPI instance...") + api = DirectAdminAPI(server, username, password, domain) + + print(f"Calling test_connection()...") success, message = api.test_connection() + print(f"Test connection result: success={success}, message={message}") - return jsonify({ + result = { 'success': success, 'message': message - }) + } + print(f"Sending response: {result}") + return jsonify(result) except Exception as e: error_msg = str(e) diff --git a/static/settings.js b/static/settings.js index 8fb1580..21e0a83 100644 --- a/static/settings.js +++ b/static/settings.js @@ -286,7 +286,10 @@ async function testConnection() { try { // Add timeout to prevent hanging const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + const timeoutId = setTimeout(() => { + console.log('Connection test timeout reached, aborting...'); + controller.abort(); + }, 15000); // 15 second timeout for faster debugging const response = await fetch('/settings/api/test-connection', { method: 'POST', From 106d81227bd3af29795ef6dd33533409db555762 Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 15:07:39 +0200 Subject: [PATCH 07/11] Test connection button fixes --- app/templates/settings.html | 2 +- static/settings.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/templates/settings.html b/app/templates/settings.html index e9ce46b..b98dabe 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -29,7 +29,7 @@

DirectAdmin Configuration

- +

diff --git a/static/settings.js b/static/settings.js index 21e0a83..ccec591 100644 --- a/static/settings.js +++ b/static/settings.js @@ -254,7 +254,7 @@ document.getElementById('new_domain').addEventListener('keypress', (e) => { // Test connection function - COMPLETELY SEPARATE -async function testConnection() { +async function testConnection(event) { console.log('testConnection called'); const testButton = event.target; const originalText = testButton.textContent; From 317522a024dc930a205bc37d477ddba5454bf1bd Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 15:15:46 +0200 Subject: [PATCH 08/11] Test button fixes --- app/templates/settings.html | 2 +- static/settings.js | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/templates/settings.html b/app/templates/settings.html index b98dabe..3eec645 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -29,7 +29,7 @@

DirectAdmin Configuration

- +

diff --git a/static/settings.js b/static/settings.js index ccec591..d7cd5d2 100644 --- a/static/settings.js +++ b/static/settings.js @@ -254,9 +254,24 @@ document.getElementById('new_domain').addEventListener('keypress', (e) => { // Test connection function - COMPLETELY SEPARATE +// Add debugging to window object +window.debugTestConnection = function() { + console.log('Debug test connection function exists'); + alert('Debug: testConnection function is accessible'); +}; + async function testConnection(event) { - console.log('testConnection called'); + console.log('testConnection called with event:', event); + alert('testConnection function called!'); // Immediate feedback + + if (!event) { + console.error('No event passed to testConnection'); + alert('Error: No event object passed to testConnection function'); + return; + } + const testButton = event.target; + console.log('testButton:', testButton); const originalText = testButton.textContent; // Ensure we always reset the button state @@ -328,6 +343,21 @@ async function testConnection(event) { } } +// Alternative event listener approach +document.addEventListener('DOMContentLoaded', function() { + console.log('DOM loaded, setting up test connection button'); + const testBtn = document.getElementById('test-connection-btn'); + if (testBtn) { + console.log('Found test connection button, adding event listener'); + testBtn.addEventListener('click', function(event) { + console.log('Test connection button clicked via event listener'); + testConnection(event); + }); + } else { + console.error('Could not find test connection button with id test-connection-btn'); + } +}); + // Helper function to show messages function showMessage(type, message) { // Remove any existing messages From 01a1ef2edea23eb68dc702e9586a094e9be4e34a Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 15:21:34 +0200 Subject: [PATCH 09/11] Test button fixes 2 --- static/settings.js | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/static/settings.js b/static/settings.js index d7cd5d2..41d251d 100644 --- a/static/settings.js +++ b/static/settings.js @@ -254,15 +254,8 @@ document.getElementById('new_domain').addEventListener('keypress', (e) => { // Test connection function - COMPLETELY SEPARATE -// Add debugging to window object -window.debugTestConnection = function() { - console.log('Debug test connection function exists'); - alert('Debug: testConnection function is accessible'); -}; - async function testConnection(event) { console.log('testConnection called with event:', event); - alert('testConnection function called!'); // Immediate feedback if (!event) { console.error('No event passed to testConnection'); @@ -283,11 +276,24 @@ async function testConnection(event) { testButton.textContent = 'Testing...'; testButton.disabled = true; + // Get form elements with null checks + const serverEl = document.getElementById('da_server'); + const usernameEl = document.getElementById('da_username'); + const passwordEl = document.getElementById('da_password'); + const domainEl = document.getElementById('da_domain'); // This might not exist + + if (!serverEl || !usernameEl || !passwordEl) { + console.error('Missing required form elements'); + alert('Error: Required form elements not found on page'); + resetButton(); + return; + } + const formData = { - da_server: document.getElementById('da_server').value.trim(), - da_username: document.getElementById('da_username').value.trim(), - da_password: document.getElementById('da_password').value, - da_domain: document.getElementById('da_domain').value.trim() + da_server: serverEl.value.trim(), + da_username: usernameEl.value.trim(), + da_password: passwordEl.value, + da_domain: domainEl ? domainEl.value.trim() : '' // Optional field }; if (!formData.da_server || !formData.da_username) { From 254780a93de7a26049b62d4b2297b64b2fd209a7 Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 15:29:34 +0200 Subject: [PATCH 10/11] Test button --- app/directadmin_api.py | 11 ++++++++--- app/settings.py | 5 ++++- static/settings.js | 19 ++++++++++++++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/directadmin_api.py b/app/directadmin_api.py index 340bbf9..2370524 100644 --- a/app/directadmin_api.py +++ b/app/directadmin_api.py @@ -201,10 +201,15 @@ def test_connection(self): domain_list.append(key) print(f"Found domains: {domain_list}") - if self.domain in domain_list: - return True, f"Successfully connected. Domain {self.domain} found." + domain_count = len(domain_list) + + if self.domain: + if self.domain in domain_list: + return True, f"Successfully connected. Domain {self.domain} found. Total domains: {domain_count} ({', '.join(domain_list[:3])}{'...' if domain_count > 3 else ''})" + else: + return True, f"Connected, but domain {self.domain} not found in account. Available domains ({domain_count}): {', '.join(domain_list[:5])}{'...' if domain_count > 5 else ''}" else: - return True, f"Connected, but domain {self.domain} not found in account. Available domains: {', '.join(domain_list[:3])}{'...' if len(domain_list) > 3 else ''}" + return True, f"Successfully connected. Found {domain_count} domains: {', '.join(domain_list[:5])}{'...' if domain_count > 5 else ''}" else: return True, "Successfully connected to DirectAdmin." else: diff --git a/app/settings.py b/app/settings.py index 77d7dd3..c3c23b3 100644 --- a/app/settings.py +++ b/app/settings.py @@ -108,7 +108,10 @@ def test_connection(): server = data.get('da_server') or current_user.da_server username = data.get('da_username') or current_user.da_username password = data.get('da_password') or current_user.get_da_password() - domain = data.get('da_domain') or current_user.da_domain + + # Get the first configured domain for testing, if any + user_domains = current_user.get_domains() + domain = user_domains[0] if user_domains else None print(f"Test connection with server: {server}, username: {username}, domain: {domain}") diff --git a/static/settings.js b/static/settings.js index 41d251d..9aa6dae 100644 --- a/static/settings.js +++ b/static/settings.js @@ -269,10 +269,13 @@ async function testConnection(event) { // Ensure we always reset the button state const resetButton = () => { + console.log('Resetting button from:', testButton.textContent, 'to:', originalText); testButton.textContent = originalText; testButton.disabled = false; + console.log('Button reset complete:', testButton.textContent, 'disabled:', testButton.disabled); }; + console.log('Setting button to Testing...'); testButton.textContent = 'Testing...'; testButton.disabled = true; @@ -310,7 +313,7 @@ async function testConnection(event) { const timeoutId = setTimeout(() => { console.log('Connection test timeout reached, aborting...'); controller.abort(); - }, 15000); // 15 second timeout for faster debugging + }, 30000); // 30 second timeout const response = await fetch('/settings/api/test-connection', { method: 'POST', @@ -332,8 +335,10 @@ async function testConnection(event) { console.log('Test response:', result); if (result.success) { + console.log('Connection test successful, showing success message'); showMessage('success', '✓ ' + result.message); } else { + console.log('Connection test failed, showing error message'); showMessage('warning', '✗ Connection failed: ' + (result.error || result.message || 'Unknown error') + '\nYou can still save these settings.'); } } catch (error) { @@ -344,8 +349,16 @@ async function testConnection(event) { showMessage('error', '✗ Test error: ' + error.message + '\nYou can still save these settings.'); } } finally { - resetButton(); - console.log('testConnection completed, button reset'); + console.log('testConnection finally block executing...'); + try { + resetButton(); + console.log('testConnection completed, button reset successfully'); + } catch (resetError) { + console.error('Error resetting button:', resetError); + // Fallback button reset + testButton.textContent = 'Test Connection'; + testButton.disabled = false; + } } } From 0c5b0271598a4d7afe4a265c76729fe421f31e0a Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Sun, 28 Sep 2025 15:33:47 +0200 Subject: [PATCH 11/11] button fix --- static/settings.js | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/static/settings.js b/static/settings.js index 9aa6dae..f8b1dc1 100644 --- a/static/settings.js +++ b/static/settings.js @@ -253,7 +253,7 @@ document.getElementById('new_domain').addEventListener('keypress', (e) => { }); -// Test connection function - COMPLETELY SEPARATE +// Test connection function - COMPLETELY SEPARATE - Updated 2025-09-28-15:30 async function testConnection(event) { console.log('testConnection called with event:', event); @@ -337,10 +337,16 @@ async function testConnection(event) { if (result.success) { console.log('Connection test successful, showing success message'); showMessage('success', '✓ ' + result.message); + console.log('About to reset button after success...'); } else { console.log('Connection test failed, showing error message'); showMessage('warning', '✗ Connection failed: ' + (result.error || result.message || 'Unknown error') + '\nYou can still save these settings.'); + console.log('About to reset button after failure...'); } + + // Force immediate button reset here as well + console.log('Forcing immediate button reset...'); + resetButton(); } catch (error) { console.error('Error testing connection:', error); if (error.name === 'AbortError') { @@ -350,14 +356,35 @@ async function testConnection(event) { } } finally { console.log('testConnection finally block executing...'); + + // Multiple attempts to reset the button try { resetButton(); console.log('testConnection completed, button reset successfully'); } catch (resetError) { - console.error('Error resetting button:', resetError); - // Fallback button reset - testButton.textContent = 'Test Connection'; + console.error('Error resetting button with resetButton():', resetError); + } + + // Always try direct button reset as backup + try { + console.log('Attempting direct button reset...'); + testButton.textContent = originalText || 'Test Connection'; testButton.disabled = false; + console.log('Direct button reset completed'); + } catch (directResetError) { + console.error('Error with direct button reset:', directResetError); + } + + // Ultimate fallback - find button by ID and reset + try { + const btn = document.getElementById('test-connection-btn'); + if (btn) { + console.log('Ultimate fallback: resetting button by ID'); + btn.textContent = 'Test Connection'; + btn.disabled = false; + } + } catch (fallbackError) { + console.error('Ultimate fallback failed:', fallbackError); } } }