diff --git a/app/directadmin_api.py b/app/directadmin_api.py index b927be5..2370524 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)}") @@ -64,12 +67,23 @@ 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, 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." + 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: 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'): + # 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" + 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}), @@ -192,7 +303,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)}") @@ -206,8 +321,21 @@ def get_email_accounts(self): # Format 1: list format if 'list' in response: - accounts = response['list'] - # Format 2: numbered keys (0, 1, 2, etc) + list_data = response['list'] + if isinstance(list_data, list): + accounts = list_data + else: + accounts = [list_data] # Single item, convert to list + + # Format 2: list[] key (from fixed parsing) + elif 'list[]' in response: + list_data = response['list[]'] + if isinstance(list_data, list): + accounts = list_data + else: + accounts = [list_data] # Single item, convert to list + + # Format 3: numbered keys (0, 1, 2, etc) elif any(key.isdigit() for key in response.keys()): for key in sorted(response.keys()): if key.isdigit() and response[key]: @@ -215,16 +343,23 @@ def get_email_accounts(self): accounts.append(response[key]) else: accounts.append(f"{response[key]}@{self.domain}") - # Format 3: email addresses as keys + + # Format 4: email addresses as keys else: for key, value in response.items(): if '@' in key and not key.startswith('error'): accounts.append(key) elif value and '@' in str(value): - accounts.append(str(value)) + if isinstance(value, list): + accounts.extend([str(v) for v in value]) + else: + accounts.append(str(value)) elif value and not key.startswith('error'): - # Might be just username - accounts.append(f"{value}@{self.domain}") + # Might be just username(s) + if isinstance(value, list): + accounts.extend([f"{v}@{self.domain}" for v in value]) + else: + accounts.append(f"{value}@{self.domain}") elif isinstance(response, str) and response: print("Response is string, parsing...") @@ -237,12 +372,38 @@ def get_email_accounts(self): elif line and not line.startswith('error'): accounts.append(f"{line}@{self.domain}") - # Remove duplicates and filter - accounts = list(set(accounts)) + # 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: + # 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: + # 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)) api_email = f"{self.username}@{self.domain}" - filtered = [email for email in accounts if email.lower() != api_email.lower()] + filtered = [email for email in processed_accounts if email.lower() != api_email.lower()] print(f"Found {len(filtered)} email accounts (excluding API user)") + for account in filtered: + print(f" - {account}") return sorted(filtered) except Exception as e: @@ -256,11 +417,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 @@ -270,17 +430,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 ===") @@ -335,6 +499,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 71e4ad5..646b3dd 100644 --- a/app/main.py +++ b/app/main.py @@ -48,17 +48,85 @@ 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 ===== + @app.route('/api/domains', methods=['GET']) + @login_required + def get_user_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 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(): - """Get all email accounts for the configured domain""" + """Get all email accounts for the specified domain""" if not current_user.has_da_config(): return jsonify({ 'error': 'DirectAdmin not configured', @@ -66,14 +134,41 @@ def get_email_accounts(): }), 400 try: + # Get domain from query parameter or use first domain + domain = request.args.get('domain') + if not domain: + domain = current_user.get_first_domain() + + if not domain: + return jsonify({ + 'error': 'No domain specified', + 'accounts': [] + }), 400 + + # Verify user has access to this domain + user_domains = current_user.get_domains() + if domain not in user_domains: + return jsonify({ + 'error': 'Access denied to domain', + 'accounts': [] + }), 403 + # Create API instance api = DirectAdminAPI( current_user.da_server, current_user.da_username, current_user.get_da_password(), - current_user.da_domain + 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() @@ -81,11 +176,12 @@ def get_email_accounts(): if not isinstance(accounts, list): accounts = [] - print(f"API returning {len(accounts)} email accounts") + print(f"API returning {len(accounts)} email accounts for domain {domain}") return jsonify({ 'success': True, - 'accounts': accounts + 'accounts': accounts, + 'domain': domain }) except Exception as e: @@ -99,7 +195,7 @@ def get_email_accounts(): @app.route('/api/forwarders', methods=['GET']) @login_required def get_forwarders(): - """Get all email forwarders""" + """Get all email forwarders for the specified domain""" if not current_user.has_da_config(): return jsonify({ 'error': 'DirectAdmin not configured', @@ -107,14 +203,41 @@ def get_forwarders(): }), 400 try: + # Get domain from query parameter or use first domain + domain = request.args.get('domain') + if not domain: + domain = current_user.get_first_domain() + + if not domain: + return jsonify({ + 'error': 'No domain specified', + 'forwarders': [] + }), 400 + + # Verify user has access to this domain + user_domains = current_user.get_domains() + if domain not in user_domains: + return jsonify({ + 'error': 'Access denied to domain', + 'forwarders': [] + }), 403 + # Create API instance api = DirectAdminAPI( current_user.da_server, current_user.da_username, current_user.get_da_password(), - current_user.da_domain + 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() @@ -122,11 +245,12 @@ def get_forwarders(): if not isinstance(forwarders, list): forwarders = [] - print(f"API returning {len(forwarders)} forwarders") + print(f"API returning {len(forwarders)} forwarders for domain {domain}") return jsonify({ 'success': True, - 'forwarders': forwarders + 'forwarders': forwarders, + 'domain': domain }) except Exception as e: @@ -152,6 +276,7 @@ def create_forwarder(): address = data.get('address', '').strip() destination = data.get('destination', '').strip() + domain = data.get('domain', '').strip() # Validate inputs if not address: @@ -160,12 +285,24 @@ def create_forwarder(): if not destination: return jsonify({'error': 'Destination email is required'}), 400 + # Get domain or use first domain + if not domain: + domain = current_user.get_first_domain() + + if not domain: + return jsonify({'error': 'No domain specified'}), 400 + + # Verify user has access to this domain + user_domains = current_user.get_domains() + if domain not in user_domains: + return jsonify({'error': 'Access denied to domain'}), 403 + # Create API instance api = DirectAdminAPI( current_user.da_server, current_user.da_username, current_user.get_da_password(), - current_user.da_domain + domain ) # Create the forwarder @@ -174,7 +311,8 @@ def create_forwarder(): if success: return jsonify({ 'success': True, - 'message': message + 'message': message, + 'domain': domain }) else: return jsonify({ @@ -202,16 +340,31 @@ def delete_forwarder(): return jsonify({'error': 'No data provided'}), 400 address = data.get('address', '').strip() + domain = data.get('domain', '').strip() if not address: return jsonify({'error': 'Email address is required'}), 400 + # Extract domain from address if not provided + if not domain and '@' in address: + domain = address.split('@')[1] + elif not domain: + domain = current_user.get_first_domain() + + if not domain: + return jsonify({'error': 'No domain specified'}), 400 + + # Verify user has access to this domain + user_domains = current_user.get_domains() + if domain not in user_domains: + return jsonify({'error': 'Access denied to domain'}), 403 + # Create API instance api = DirectAdminAPI( current_user.da_server, current_user.da_username, current_user.get_da_password(), - current_user.da_domain + domain ) # Delete the forwarder @@ -220,7 +373,8 @@ def delete_forwarder(): if success: return jsonify({ 'success': True, - 'message': message + 'message': message, + 'domain': domain }) else: return jsonify({ @@ -275,7 +429,100 @@ def handle_exception(error): 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() diff --git a/app/models.py b/app/models.py index 9278d61..ae0e47b 100644 --- a/app/models.py +++ b/app/models.py @@ -9,6 +9,28 @@ db = SQLAlchemy() +class UserDomain(db.Model): + """Model for storing multiple domains per user""" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + domain = db.Column(db.String(255), nullable=False) + order_index = db.Column(db.Integer, nullable=False, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationship back to user + user = db.relationship('User', backref=db.backref('domains', lazy=True, order_by='UserDomain.order_index')) + + def __repr__(self): + return f'' + + 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) @@ -92,12 +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, - self.da_domain - ]) + self.da_password_encrypted + ]) and has_domains + + # ===== Domain Management ===== + + def get_domains(self): + """Get all domains for this user in order""" + 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""" + 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""" + 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""" + 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""" + 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 ===== @@ -155,7 +279,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..c3c23b3 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'): @@ -96,33 +100,182 @@ 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() + + # 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}") 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) + print(f"Test connection error: {error_msg}") + print(traceback.format_exc()) + + # 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 +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"Test connection error: {str(e)}") + print(f"Error adding domain: {str(e)}") print(traceback.format_exc()) - return jsonify({'error': 'An internal error has occurred.', 'success': False}), 200 + 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 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..3eec645 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -27,78 +27,40 @@

DirectAdmin Configuration

Your DirectAdmin API password (encrypted storage) +
+ + +
+ +

+ 💡 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.

+ +
- - - The domain you want to manage forwarders for + +
+ + +
+ Enter a domain name to manage forwarders for
- -
- - +
+

Current Domains

+
+

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

+
+
-

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

- - - - - -
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 diff --git a/static/dashboard.js b/static/dashboard.js index dc2eba1..1eabe68 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,10 +123,19 @@ async function loadEmailAccounts() { updateDestinationDropdown(); } else { console.error('Failed to load email accounts:', data.error); - showMessage('Failed to load email accounts', '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(); } } @@ -92,8 +199,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 +226,12 @@ async function loadForwarders() { } catch (error) { console.error('Error loading forwarders:', error); - tbody.innerHTML = 'Failed to load forwarders. 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.'; + } } } @@ -209,7 +326,8 @@ async function createForwarder(event) { }, body: JSON.stringify({ address: addressInput.value.trim(), - destination: destination + destination: destination, + domain: selectedDomain }) }); @@ -261,7 +379,8 @@ async function deleteForwarder(address) { 'Content-Type': 'application/json', }, body: JSON.stringify({ - address: address + address: address, + domain: selectedDomain }) }); @@ -327,11 +446,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..f8b1dc1 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,61 +101,309 @@ 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(); } }); -// Test connection function - COMPLETELY SEPARATE -async function testConnection() { +// Test connection function - COMPLETELY SEPARATE - Updated 2025-09-28-15:30 +async function testConnection(event) { + console.log('testConnection called with event:', event); + + 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 + 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; + // 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) { 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(() => { + console.log('Connection test timeout reached, aborting...'); + 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) { + console.log('Connection test successful, showing success message'); showMessage('success', '✓ ' + result.message); + console.log('About to reset button after success...'); } else { - showMessage('warning', '✗ Connection failed: ' + (result.error || 'Unknown error') + '\nYou can still save these settings.'); + 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); - 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; + 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 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); + } } } +// 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 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; +}