/** * Main Application Controller * Handles authentication, data loading, and view rendering */ class MailingListApp { constructor() { this.isAuthenticated = false; this.currentUser = null; this.lists = []; this.members = []; this.users = []; this.subscriptions = new Map(); // list_id -> members[] this.initializeApp(); } /** * Initialize the application */ async initializeApp() { this.setupEventListeners(); // Check for saved token const savedToken = localStorage.getItem('authToken'); if (savedToken) { apiClient.setToken(savedToken); await this.checkCurrentUser(); } } /** * Setup event listeners */ setupEventListeners() { // Login form submission document.getElementById('loginForm').addEventListener('submit', (e) => { e.preventDefault(); this.handleLogin(); }); // Clear error when user starts typing document.getElementById('username').addEventListener('input', () => { this.clearLoginError(); }); document.getElementById('password').addEventListener('input', () => { this.clearLoginError(); }); document.getElementById('logoutBtn').addEventListener('click', () => { this.logout(); }); // Bulk import button document.getElementById('showBulkImportBtn').addEventListener('click', () => { uiManager.showBulkImportModal(); }); // Add user button (admin only) document.getElementById('addUserBtn').addEventListener('click', () => { uiManager.showUserModal(); }); } /** * Check if current user is still valid */ async checkCurrentUser() { try { const user = await apiClient.getCurrentUser(); this.currentUser = user; this.isAuthenticated = true; this.showAuthenticatedUI(); await this.loadData(); } catch (error) { // Token is invalid, clear it this.logout(); } } /** * Handle login button click */ async handleLogin() { const usernameInput = document.getElementById('username'); const passwordInput = document.getElementById('password'); const username = usernameInput.value.trim(); const password = passwordInput.value.trim(); // Clear previous error states this.clearLoginError(); if (!username || !password) { this.showLoginError('Please enter both username and password'); return; } await this.login(username, password); } /** * Show login error message */ showLoginError(message) { const errorDiv = document.getElementById('loginError'); const errorMessage = document.getElementById('loginErrorMessage'); const usernameInput = document.getElementById('username'); const passwordInput = document.getElementById('password'); errorMessage.textContent = message; errorDiv.style.display = 'flex'; // Add error class to inputs usernameInput.classList.add('error'); passwordInput.classList.add('error'); // Remove animation class and re-add to trigger animation errorDiv.style.animation = 'none'; setTimeout(() => { errorDiv.style.animation = ''; }, 10); } /** * Clear login error message */ clearLoginError() { const errorDiv = document.getElementById('loginError'); const usernameInput = document.getElementById('username'); const passwordInput = document.getElementById('password'); errorDiv.style.display = 'none'; usernameInput.classList.remove('error'); passwordInput.classList.remove('error'); } /** * Authenticate with API */ async login(username, password) { try { uiManager.setLoading(true); // Login and get token const response = await apiClient.login(username, password); this.currentUser = response.user; this.isAuthenticated = true; // Save token localStorage.setItem('authToken', response.access_token); this.showAuthenticatedUI(); await this.loadData(); uiManager.showNotification(`Welcome back, ${this.currentUser.username}!`, 'success'); } catch (error) { this.isAuthenticated = false; this.currentUser = null; apiClient.clearToken(); localStorage.removeItem('authToken'); // Show error on login form let errorMessage = 'Login failed'; if (error.message) { if (error.message.includes('401') || error.message.includes('Unauthorized') || error.message.includes('Invalid') || error.message.includes('credentials')) { errorMessage = 'Invalid username or password'; } else if (error.message.includes('network') || error.message.includes('fetch')) { errorMessage = 'Unable to connect to server'; } else { errorMessage = error.message; } } this.showLoginError(errorMessage); } finally { uiManager.setLoading(false); } } /** * Logout */ async logout() { try { await apiClient.logout(); } catch (error) { // Ignore logout errors } this.isAuthenticated = false; this.currentUser = null; apiClient.clearToken(); localStorage.removeItem('authToken'); this.showUnauthenticatedUI(); uiManager.showNotification('Logged out successfully', 'info'); } /** * Show authenticated UI */ showAuthenticatedUI() { document.getElementById('loginPage').style.display = 'none'; document.getElementById('mainHeader').style.display = 'block'; document.getElementById('mainContent').style.display = 'block'; // Clear login inputs document.getElementById('username').value = ''; document.getElementById('password').value = ''; // Update user info display if (this.currentUser) { document.getElementById('currentUsername').textContent = this.currentUser.username; document.getElementById('currentUserRole').textContent = this.currentUser.role; // Show/hide admin-only features const isAdmin = this.currentUser.role === 'administrator'; document.getElementById('usersTab').style.display = isAdmin ? 'block' : 'none'; // Show/hide write access features const hasWriteAccess = this.currentUser.role === 'administrator' || this.currentUser.role === 'operator'; this.updateUIForPermissions(hasWriteAccess); } } /** * Show unauthenticated UI */ showUnauthenticatedUI() { document.getElementById('loginPage').style.display = 'flex'; document.getElementById('mainHeader').style.display = 'none'; document.getElementById('mainContent').style.display = 'none'; } /** * Update UI elements based on user permissions */ updateUIForPermissions(hasWriteAccess) { // Disable/enable write action buttons const writeButtons = document.querySelectorAll('[data-requires-write]'); writeButtons.forEach(button => { button.style.display = hasWriteAccess ? '' : 'none'; }); // Update button attributes for later reference document.getElementById('addListBtn').setAttribute('data-requires-write', ''); document.getElementById('addMemberBtn').setAttribute('data-requires-write', ''); document.getElementById('showBulkImportBtn').setAttribute('data-requires-write', ''); } /** * Load all data from API */ async loadData() { if (!this.isAuthenticated) return; try { uiManager.setLoading(true); // Load lists and members in parallel const [lists, members] = await Promise.all([ apiClient.getLists(), apiClient.getMembers() ]); this.lists = lists; this.members = members; // Load users if admin if (this.currentUser && this.currentUser.role === 'administrator') { try { this.users = await apiClient.getUsers(); } catch (error) { console.warn('Failed to load users:', error); this.users = []; } } // Load subscriptions for each list await this.loadSubscriptions(); // Render all views this.renderLists(); this.renderMembers(); if (this.currentUser && this.currentUser.role === 'administrator') { this.renderUsers(); } } catch (error) { uiManager.handleError(error, 'Failed to load data'); } finally { uiManager.setLoading(false); } } /** * Load subscription data for all lists */ async loadSubscriptions() { this.subscriptions.clear(); const subscriptionPromises = this.lists.map(async (list) => { try { const members = await apiClient.getListMembers(list.list_id); this.subscriptions.set(list.list_id, members); } catch (error) { console.warn(`Failed to load members for list ${list.list_id}:`, error); this.subscriptions.set(list.list_id, []); } }); await Promise.all(subscriptionPromises); } /** * Render mailing lists table */ renderLists() { const tbody = document.getElementById('listsTableBody'); tbody.innerHTML = ''; if (this.lists.length === 0) { tbody.innerHTML = ` No mailing lists found. Create your first list `; document.getElementById('createFirstList').addEventListener('click', (e) => { e.preventDefault(); uiManager.showListModal(); }); return; } const hasWriteAccess = this.currentUser && (this.currentUser.role === 'administrator' || this.currentUser.role === 'operator'); this.lists.forEach(list => { const row = document.createElement('tr'); const memberCount = this.subscriptions.get(list.list_id)?.length || 0; row.innerHTML = `
${uiManager.escapeHtml(list.list_name)}
${uiManager.escapeHtml(list.list_email)}
${list.description ? uiManager.escapeHtml(list.description) : 'No description'}
${memberCount} ${memberCount === 1 ? 'member' : 'members'}
`; // Add status badge const statusCell = row.cells[4]; statusCell.appendChild(uiManager.createStatusBadge(list.active)); // Add action buttons only for users with write access const actionsCell = row.cells[5].querySelector('.action-buttons'); if (hasWriteAccess) { const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => { uiManager.showListModal(list); }); const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => { uiManager.showConfirmation( `Are you sure you want to delete the mailing list "${list.list_name}"? This action cannot be undone.`, async () => { await this.deleteList(list.list_id); } ); }); actionsCell.appendChild(editBtn); actionsCell.appendChild(deleteBtn); } tbody.appendChild(row); }); } /** * Render members table */ renderMembers() { const tbody = document.getElementById('membersTableBody'); tbody.innerHTML = ''; if (this.members.length === 0) { tbody.innerHTML = ` No members found. Add your first member `; document.getElementById('createFirstMember').addEventListener('click', (e) => { e.preventDefault(); uiManager.showMemberModal(); }); return; } const hasWriteAccess = this.currentUser && (this.currentUser.role === 'administrator' || this.currentUser.role === 'operator'); this.members.forEach(member => { const row = document.createElement('tr'); // Find lists this member belongs to const memberLists = []; this.subscriptions.forEach((members, listId) => { if (members.some(m => m.member_id === member.member_id)) { const list = this.lists.find(l => l.list_id === listId); if (list) { memberLists.push(list.list_name); } } }); row.innerHTML = `
${uiManager.escapeHtml(member.name)}
${member.bounce_count > 0 ? `
` : ''} ${uiManager.escapeHtml(member.email)}
${memberLists.length > 0 ? memberLists.map(name => `${uiManager.escapeHtml(name)}`).join(', ') : 'No subscriptions' }
`; // Add bounce badge if member has bounces if (member.bounce_count > 0) { const bounceInfoDiv = row.cells[0].querySelector('.text-xs'); const bounceBadge = uiManager.createBounceStatusBadge(member.bounce_status, member.bounce_count); if (bounceBadge) { bounceInfoDiv.appendChild(bounceBadge); } } // Add status badge const statusCell = row.cells[3]; statusCell.appendChild(uiManager.createStatusBadge(member.active)); // Add action buttons const actionsCell = row.cells[4].querySelector('.action-buttons'); // Create Lists button with proper subscription modal functionality const subscriptionsBtn = document.createElement('button'); subscriptionsBtn.className = 'btn btn-sm btn-primary'; subscriptionsBtn.innerHTML = ' Lists'; subscriptionsBtn.title = 'Manage Subscriptions'; subscriptionsBtn.addEventListener('click', () => { uiManager.showMemberSubscriptionsModal(member); }); // Create Bounces button (show if member has any bounces or for admins/operators) if (member.bounce_count > 0 || hasWriteAccess) { const bouncesBtn = document.createElement('button'); bouncesBtn.className = `btn btn-sm ${member.bounce_count > 0 ? 'btn-warning' : 'btn-secondary'}`; bouncesBtn.innerHTML = ` Bounces${member.bounce_count > 0 ? ` (${member.bounce_count})` : ''}`; bouncesBtn.title = 'View Bounce History'; bouncesBtn.addEventListener('click', () => { uiManager.showBounceHistoryModal(member); }); actionsCell.appendChild(bouncesBtn); } // Only show edit/delete buttons for users with write access if (hasWriteAccess) { const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => { uiManager.showMemberModal(member); }); const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => { uiManager.showConfirmation( `Are you sure you want to delete the member "${member.name}"? This will remove them from all mailing lists.`, async () => { await this.deleteMember(member.member_id); } ); }); actionsCell.appendChild(subscriptionsBtn); actionsCell.appendChild(editBtn); actionsCell.appendChild(deleteBtn); } else { actionsCell.appendChild(subscriptionsBtn); } tbody.appendChild(row); }); } /** * Render users table (admin only) */ renderUsers() { const tbody = document.getElementById('usersTableBody'); tbody.innerHTML = ''; if (this.users.length === 0) { tbody.innerHTML = ` No users found. Create your first user `; document.getElementById('createFirstUser').addEventListener('click', (e) => { e.preventDefault(); uiManager.showUserModal(); }); return; } this.users.forEach(user => { const row = document.createElement('tr'); // Format dates const createdAt = new Date(user.created_at).toLocaleDateString(); const lastLogin = user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never'; row.innerHTML = `
${uiManager.escapeHtml(user.username)}
${user.role.replace('-', ' ')}
${createdAt}
${lastLogin}
`; // Add status badge const statusCell = row.cells[4]; statusCell.appendChild(uiManager.createStatusBadge(user.active)); // Add action buttons const actionsCell = row.cells[5].querySelector('.action-buttons'); const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => { uiManager.showUserModal(user); }); // Don't allow deletion of current user if (user.user_id !== this.currentUser.user_id) { const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => { uiManager.showConfirmation( `Are you sure you want to delete the user "${user.username}"? This action cannot be undone.`, async () => { await this.deleteUser(user.user_id); } ); }); actionsCell.appendChild(editBtn); actionsCell.appendChild(deleteBtn); } else { actionsCell.appendChild(editBtn); } tbody.appendChild(row); }); } /** * Delete a mailing list */ async deleteList(listId) { try { uiManager.setLoading(true); await apiClient.deleteList(listId); uiManager.showNotification('Mailing list deleted successfully', 'success'); await this.loadData(); } catch (error) { uiManager.handleError(error, 'Failed to delete mailing list'); } finally { uiManager.setLoading(false); } } /** * Delete a member */ async deleteMember(memberId) { try { uiManager.setLoading(true); await apiClient.deleteMember(memberId); uiManager.showNotification('Member deleted successfully', 'success'); await this.loadData(); } catch (error) { uiManager.handleError(error, 'Failed to delete member'); } finally { uiManager.setLoading(false); } } /** * Unsubscribe member from list */ async unsubscribeMember(listEmail, memberEmail) { try { uiManager.setLoading(true); await apiClient.deleteSubscription(listEmail, memberEmail); uiManager.showNotification('Member unsubscribed successfully', 'success'); await this.loadData(); } catch (error) { uiManager.handleError(error, 'Failed to unsubscribe member'); } finally { uiManager.setLoading(false); } } /** * Delete a user */ async deleteUser(userId) { try { uiManager.setLoading(true); await apiClient.deleteUser(userId); uiManager.showNotification('User deleted successfully', 'success'); await this.loadData(); } catch (error) { uiManager.handleError(error, 'Failed to delete user'); } finally { uiManager.setLoading(false); } } } // Initialize the application when DOM is loaded document.addEventListener('DOMContentLoaded', () => { window.app = new MailingListApp(); });