1/** 2 * Authentication Module 3 * 4 * Uses Firebase Auth for secure authentication with fallback to legacy system. 5 * Supports: 6 * - Email/password login via Firebase 7 * - Password reset via Firebase 8 * - Role-based access with 6 roles: 9 * - super_admin: Platform administrators (hardcoded) 10 * - agency_owner: Creates brands, invites brand_user 11 * - brand_owner: Upgraded from trial, invites brand_collaborator 12 * - trial_user: Self-signup, limited trial period 13 * - brand_user: Invited by agency_owner, configurable permissions 14 * - brand_collaborator: Invited by brand_owner, configurable permissions 15 */ 16 17// Super admin users (platform administrators) 18const SUPER_ADMIN_EMAILS = [ 19 '[email protected]', 20 '[email protected]', 21 '[email protected]' 22]; 23 24// Legacy admin users (for backward compatibility during transition) 25const LEGACY_ADMINS = [ 26 { email: '[email protected]', password: 'Woods123!' }, 27 { email: '[email protected]', password: 'Woods123!' }, 28 { email: '[email protected]', password: 'Woods123!' } 29]; 30 31let firebaseReady = false; 32 33// Initialize on page load 34document.addEventListener('DOMContentLoaded', async () => { 35 // Try to restore session from cookie (OAuth callback) 36 restoreSessionFromCookie(); 37 38 // Initialize Firebase 39 try { 40 const fb = await window.FirebaseAuth?.initialize(); 41 if (fb) { 42 firebaseReady = true; 43 44 // Listen for auth state changes 45 window.FirebaseAuth.onAuthStateChanged(async (user) => { 46 // Clear logout flag when auth state changes (user signed out or in) 47 const justLoggedOut = sessionStorage.getItem('justLoggedOut') === 'true'; 48 if (justLoggedOut) { 49 sessionStorage.removeItem('justLoggedOut'); 50 } 51 52 if (user) { 53 54 // Don't auto-login if user just logged out (flag was set before this callback) 55 if (justLoggedOut) { 56 return; 57 } 58 59 // User is signed in - check if session already exists 60 if (sessionStorage.getItem('isAuthenticated') !== 'true') { 61 await handleFirebaseUser(user); 62 } 63 } 64 }); 65 } 66 } catch (error) { 67 console.warn('Firebase not available, using legacy auth:', error.message); 68 } 69 70 // Check if already authenticated 71 checkAuth(); 72}); 73 74/** 75 * Handle Firebase user sign-in 76 */ 77async function handleFirebaseUser(user) { 78 const email = user.email.toLowerCase(); 79 80 // Check if super admin 81 if (SUPER_ADMIN_EMAILS.includes(email)) { 82 // Store token for dashboard pages (avoids loading Firebase SDK on every page) 83 const token = await user.getIdToken(); 84 localStorage.setItem('authToken', token); 85 localStorage.setItem('authTokenExpiry', (Date.now() + 3600000).toString()); // 1 hour 86 87 sessionStorage.setItem('isAuthenticated', 'true'); 88 sessionStorage.setItem('userEmail', email); 89 sessionStorage.setItem('userRole', 'super_admin'); 90 sessionStorage.setItem('permissions', ''); 91 redirectBasedOnRole('super_admin'); 92 return; 93 } 94 95 // Get user role from MongoDB 96 try { 97 const token = await user.getIdToken(); 98 99 // Store token for dashboard pages (avoids loading Firebase SDK on every page) 100 localStorage.setItem('authToken', token); 101 localStorage.setItem('authTokenExpiry', (Date.now() + 3600000).toString()); // 1 hour 102 103 const response = await fetch('/api/auth/user-role', { 104 headers: { 105 'Authorization': `Bearer ${token}`, 106 'Content-Type': 'application/json' 107 } 108 }); 109 110 let role = 'trial_user'; 111 let permissions = null; 112 let userData = null; 113 114 if (response.ok) { 115 userData = await response.json(); 116 role = userData.role || 'trial_user'; 117 permissions = userData.permissions || null; 118 } 119 120 // Map permissions to new field names (support both old and new names from DB) 121 let mappedPermissions = null; 122 if (permissions) { 123 mappedPermissions = { 124 canAccessMetaAds: permissions.canAccessMetaAds ?? permissions.canViewMetrics ?? true, 125 canAccessCreativeStudio: permissions.canAccessCreativeStudio ?? permissions.canGenerateCreatives ?? true, 126 canAccessCreativePerformance: permissions.canAccessCreativePerformance ?? true, 127 canAccessBrandSettings: permissions.canAccessBrandSettings ?? permissions.canEditBrand ?? false, 128 canExecuteActions: permissions.canExecuteActions ?? permissions.canExecuteAIActions ?? true, 129 canAccessPhone: permissions.canAccessPhone ?? false, 130 canAccessSeoGeo: permissions.canAccessSeoGeo ?? false 131 }; 132 } 133 134 // Store basic session info 135 sessionStorage.setItem('isAuthenticated', 'true'); 136 sessionStorage.setItem('userEmail', email); 137 sessionStorage.setItem('userRole', role); 138 sessionStorage.setItem('permissions', mappedPermissions ? JSON.stringify(ma
138ppedPermissions) : ''); 139 140 // Store access and subscription info for freemium model 141 if (userData?.accessVia) { 142 sessionStorage.setItem('accessVia', userData.accessVia); 143 } 144 if (userData?.invitedByRole) { 145 sessionStorage.setItem('invitedByRole', userData.invitedByRole); 146 } 147 if (userData?.subscription) { 148 sessionStorage.setItem('subscription', JSON.stringify(userData.subscription)); 149 } 150 // Store hasFullAccess for immediate sidebar/page-access checks (avoids race condition) 151 if (userData?.hasFullAccess !== undefined) { 152 sessionStorage.setItem('hasFullAccess', userData.hasFullAccess ? 'true' : 'false'); 153 } 154 155 // For single-brand users, fetch and cache their brand data 156 const isSingleBrandRole = !['super_admin', 'agency_owner'].includes(role); 157 if (isSingleBrandRole) { 158 try { 159 const brandsResponse = await fetch('/api/brands', { 160 headers: { 161 'Authorization': `Bearer ${token}`, 162 'Content-Type': 'application/json' 163 } 164 }); 165 const brandsData = await brandsResponse.json(); 166 167 if (brandsData.success && brandsData.brands && brandsData.brands.length > 0) { 168 const brand = brandsData.brands[0]; 169 sessionStorage.setItem('userBrand', JSON.stringify(brand)); 170 } else { 171 console.warn('No brands returned for user. Response:', brandsData); 172 } 173 } catch (brandError) { 174 console.warn('Could not load brand at login:', brandError.message); 175 } 176 } 177 178 // Redirect based on role 179 redirectBasedOnRole(role); 180 } catch (error) { 181 console.error('Error getting user role:', error); 182 // Default to trial_user role 183 sessionStorage.setItem('isAuthenticated', 'true'); 184 sessionStorage.setItem('userEmail', email); 185 sessionStorage.setItem('userRole', 'trial_user'); 186 redirectBasedOnRole('trial_user'); 187 } 188} 189 190/** 191 * Redirect user based on their role and permissions 192 */ 193function redirectBasedOnRole(role) { 194 // Owner roles go to main dashboard 195 if (['super_admin', 'agency_owner', 'brand_owner', 'trial_user'].includes(role)) { 196 window.location.href = '/meta-performance-manager.html'; 197 return; 198 } 199 200 // For brand_user/brand_collaborator, redirect based on permissions 201 const permsStr = sessionStorage.getItem('permissions'); 202 if (permsStr) { 203 try { 204 const perms = JSON.parse(permsStr); 205 // Redirect to first accessible page (Meta Ads first as it's always unlocked) 206 if (perms.canAccessMetaAds) { 207 window.location.href = '/meta-performance-manager.html'; 208 } else if (perms.canAccessCreativeStudio) { 209 window.location.href = '/creative-studio.html'; 210 } else if (perms.canAccessBrandSettings) { 211 window.location.href = '/brand.html'; 212 } else { 213 // No page access - go to settings 214 window.location.href = '/settings.html'; 215 } 216 return; 217 } catch (e) { 218 console.warn('Error parsing permissions:', e); 219 } 220 } 221 222 // Default fallback - Meta Ads Manager is always accessible 223 window.location.href = '/meta-performance-manager.html'; 224} 225 226/** 227 * Restore session from OAuth callback cookie 228 */ 229function restoreSessionFromCookie() { 230 try { 231 if (sessionStorage.getItem('isAuthenticated') === 'true') { 232 document.cookie = 'session_restore=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; 233 return false; 234 } 235 236 const cookies = document.cookie.split(';'); 237 for (const cookie of cookies) { 238 const [name, value] = cookie.trim().split('='); 239 if (name === 'session_restore' && value) { 240 const sessionData = JSON.parse(decodeURIComponent(value)); 241 if (sessionData.email && sessionData.isAuthenticated) { 242 sessionStorage.setItem('isAuthenticated', 'true'); 243 sessionStorage.setItem('userEmail', sessionData.email); 244 sessionStorage.setItem('userRole', sessionData.role || 'admin'); 245 document.cookie = 'session_restore=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; 246 return true; 247 } 248 } 249 } 250 } catch (e) {
251 console.error('Failed to restore session from cookie:', e); 252 } 253 return false; 254} 255 256/** 257 * Check if user is already authenticated 258 */ 259function checkAuth() { 260 const isAuthenticated = sessionStorage.getItem('isAuthenticated'); 261 if (isAuthenticated === 'true') { 262 // Verify auth token exists in localStorage (added after initial release) 263 // If session exists but token is missing, clear session and force re-login 264 const hasToken = window.AuthHelper?.hasValidToken?.() ?? localStorage.getItem('authToken'); 265 if (!hasToken) { 266 console.warn('Session exists but auth token missing. Clearing session for re-login.'); 267 sessionStorage.clear(); 268 localStorage.removeItem('authToken'); 269 localStorage.removeItem('authTokenExpiry'); 270 // Don't redirect - let user login again 271 return; 272 } 273 274 const userRole = sessionStorage.getItem('userRole') || 'admin'; 275 redirectBasedOnRole(userRole); 276 } 277} 278 279/** 280 * Login form handler 281 */ 282document.getElementById('loginForm').addEventListener('submit', async function(e) { 283 e.preventDefault(); 284 285 const email = document.getElementById('loginEmail').value.trim().toLowerCase(); 286 const password = document.getElementById('loginPassword').value; 287 288 // Try Firebase Auth first 289 if (firebaseReady) { 290 try { 291 showMessage('Signing in...', 'info'); 292 const userCredential = await window.FirebaseAuth.signIn(email, password); 293 showMessage('Login successful! Redirecting...', 'success'); 294 // Handle login directly instead of relying on onAuthStateChanged 295 await handleFirebaseUser(userCredential.user); 296 return; 297 } catch (firebaseError) { 298 299 // Handle specific Firebase errors 300 if (firebaseError.code === 'auth/user-not-found' || 301 firebaseError.code === 'auth/wrong-password' || 302 firebaseError.code === 'auth/invalid-credential') { 303 // Try legacy auth for hardcoded admins 304 } else if (firebaseError.code === 'auth/too-many-requests') { 305 showMessage('Too many failed attempts. Please try again later.', 'error'); 306 return; 307 } else { 308 // For other Firebase errors, still try legacy 309 console.warn('Firebase error:', firebaseError.code); 310 } 311 } 312 } 313 314 // Legacy authentication for super admins 315 const validUser = LEGACY_ADMINS.find(user => 316 user.email.toLowerCase() === email && user.password === password 317 ); 318 319 if (validUser) { 320 sessionStorage.setItem('isAuthenticated', 'true'); 321 sessionStorage.setItem('userEmail', email); 322 sessionStorage.setItem('userRole', 'super_admin'); 323 sessionStorage.setItem('permissions', ''); 324 325 showMessage('Login successful! Redirecting...', 'success'); 326 setTimeout(() => { 327 window.location.href = '/meta-performance-manager.html'; 328 }, 1000); 329 return; 330 } 331 332 // Try MongoDB brand user login (legacy) 333 try { 334 showMessage('Checking credentials...', 'info'); 335 const response = await fetch('/api/auth/login', { 336 method: 'POST', 337 headers: { 'Content-Type': 'application/json' }, 338 body: JSON.stringify({ email, password }) 339 }); 340 341 const data = await response.json(); 342 343 if (data.success) { 344 const role = data.user.role; 345 sessionStorage.setItem('isAuthenticated', 'true'); 346 sessionStorage.setItem('userEmail', email); 347 sessionStorage.setItem('userRole', role); 348 sessionStorage.setItem('permissions', data.user.permissions ? JSON.stringify(data.user.permissions) : ''); 349 350 // For single-brand users, cache their brand 351 if (data.user.brand) { 352 sessionStorage.setItem('userBrand', JSON.stringify(data.user.brand)); 353 } 354 355 showMessage('Login successful! Redirecting...', 'success'); 356 setTimeout(() => redirectBasedOnRole(role), 1000); 357 } else if (data.code === 'TRIAL_EXPIRED') { 358 showTrialExpiredModal(data.trialEndsAt); 359 } else { 360 showMessage(data.error || 'Invalid email or password', 'error'); 361 } 362 } catch (error) { 363 console.error('Login error:', error); 364 showMessage('Invalid email or password', 'error'); 365 } 366}); 367 368/** 369 * Register form handler - Self-signup for freemium trial 370 */ 371document.getElementById('registerForm').addEventListener('submit', async function(e) { 372 e.preventDefault(); 373 374 const name = document.getElementById('registerName').value.trim(); 375 const email = document.getElementById('registerEmail').value.trim().toLowerCase(); 376 const company = document.getElementById('registerCompany').value.trim(); 377 const accountType = document.getElementById('registerAccountType').value; 378 const website = document.getElementById('registerWebsite')?.value?.trim() || ''; 379 const password = document.getElementById('registerPassword').value; 380 const adSpend = document.getElementById('registerAdSpend').value; 381 382 if (!name || !email || !company || !accountType || !password || !adSpend) { 383 showMessage('Please fill in all required fields', 'error'); 384 return; 385 } 386 387 if (password.length < 6) { 388 showMessage('Password must be at least 6 characters', 'error'); 389 return; 390 } 391 392 // Check if Firebase is ready 393 if (!firebaseReady) { 394 showMessage('Authentication system is loading. Please try again.', 'error'); 395 return; 396 } 397 398 try { 399 showMessage('Creating your account...', 'info'); 400 401 // Create Firebase user 402 const userCredential = await window.FirebaseAuth.createUser(email, password); 403 const user = userCredential.user; 404 405 // Get Firebase token 406 const idToken = await user.getIdToken(); 407 408 // Register in MongoDB via API 409 const response = await fetch('/api/auth/register', { 410 method: 'POST', 411 headers: { 412 'Content-Type': 'application/json', 413 'Authorization': `Bearer ${idToken}` 414 }, 415 body: JSON.stringify({ 416 name, 417 company, 418 adSpend, 419 accountType, 420 website: website || null 421 }) 422 }); 423 424 const data = await response.json(); 425 426 if (data.success) { 427 // Store auth token for dashboard pages 428 localStorage.setItem('authToken', idToken); 429 localStorage.setItem('authTokenExpiry', (Date.now() + 3600000).toString()); // 1 hour 430
431 // Get role from API response (for existing users) or default to trial_user 432 const userRole = data.user?.role || 'trial_user'; 433 434 // Store session info 435 sessionStorage.setItem('isAuthenticated', 'true'); 436 sessionStorage.setItem('userEmail', email); 437 sessionStorage.setItem('userRole', userRole); 438 sessionStorage.setItem('permissions', ''); 439 440 // Store brand ID if auto-created 441 if (data.user?.brandId) { 442 sessionStorage.setItem('currentBrandId', data.user.brandId); 443 } 444 445 const message = data.user?.isNew === false ? 'Welcome back! Redirecting...' : 'Account created! Redirecting...'; 446 showMessage(message, 'success'); 447 setTimeout(() => { 448 // Use consistent redirect logic based on role 449 redirectBasedOnRole(userRole); 450 }, 1000); 451 } else { 452 throw new Error(data.error || 'Registration failed'); 453 } 454 } catch (error) { 455 console.error('Registration error:', error); 456 457 // Handle specific Firebase errors 458 let errorMessage = error.message; 459 if (error.code === 'auth/email-already-in-use') { 460 errorMessage = 'An account with this email already exists. Please sign in instead.'; 461 } else if (error.code === 'auth/weak-password') { 462 errorMessage = 'Password is too weak. Please use at least 6 characters.'; 463 } else if (error.code === 'auth/invalid-email') { 464 errorMessage = 'Invalid email address.'; 465 } 466 467 showMessage(errorMessage, 'error'); 468 } 469}); 470 471/** 472 * Show trial expired modal 473 */ 474function showTrialExpiredModal(trialEndsAt) { 475 const expiredDate = trialEndsAt ? new Date(trialEndsAt).toLocaleDateString() : 'recently'; 476 477 let modal = document.getElementById('trialExpiredModal'); 478 if (!modal) { 479 modal = document.createElement('div'); 480 modal.id = 'trialExpiredModal'; 481 modal.innerHTML = ` 482 <div style=" 483 position: fixed; 484 inset: 0; 485 background: rgba(0, 0, 0, 0.6); 486 backdrop-filter: blur(4px); 487 display: flex; 488 align-items: center; 489 justify-content: center; 490 z-index: 10000; 491 "> 492 <div style=" 493 background: white; 494 border-radius: 16px; 495 padding: 2.5rem; 496 max-width: 420px; 497 text-align: center; 498 box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); 499 "> 500 <div style=" 501 width: 64px; 502 height: 64px; 503 background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); 504 border-radius: 16px; 505 display: flex; 506 align-items: center; 507 justify-content: center; 508 margin: 0 auto 1.5rem; 509 "> 510 <i class="fas fa-clock" style="color: white; font-size: 1.75rem;"></i> 511 </div> 512 <h2 style="margin: 0 0 0.75rem; font-size: 1.5rem; color: #1f2937;">Your Trial Has Ended</h2> 513 <p style="margin: 0 0 0.5rem; color: #6b7280; font-size: 1rem; line-height: 1.6;"> 514 Your free trial expired on <strong>${expiredDate}</strong>. 515 </p> 516 <p style="margin: 0 0 2rem; color: #6b7280; font-size: 1rem; line-height: 1.6;"> 517 Upgrade now to continue using AI-powered campaign optimization. 518 </p> 519 <a href="https://calendly.com/conor-xpathlabs/meeting" target="_blank" rel="noopener noreferrer" style=" 520 display: inline-flex; 521 align-items: center; 522 gap: 0.5rem; 523 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 524 color: white; 525 padding: 0.875rem 2rem; 526 border-radius: 10px; 527 font-weight: 600; 528 font-size: 1rem; 529 text-decoration: none; 530 box-shadow: 0 4px 14px rgba(102, 126, 234, 0.4); 531 "> 532 <i class="fas fa-calendar-check"></i> 533 Book a Call to Upgrade 534 </a> 535 <p style="margin: 1.5rem 0 0; font-size: 0.875rem; color: #9ca3af;"> 536 Questions? Email us at <a href="mailto:[email protected]" style="color: #667eea;">[email protected]</a> 537 </p> 538 </div> 539 </div> 540 `; 541 document.body.appendChild(modal); 542 } 543 544 modal.style.display = 'block'; 545} 546 547/** 548 * Show message helper 549 */ 550function showMessage(message, type) { 551 const existingMsg = document.querySelector('.auth-message'); 552 if (existingMsg) { 553 existingMsg.remove(); 554 } 555 556 const msgDiv = document.createElement('div'); 557 msgDiv.className = `auth-message auth-message-${type}`; 558 msgDiv.innerHTML = ` 559 <i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i> 560 <span>${message}</span> 561 `; 562 563 const authCard = document.querySelector('.auth-card'); 564 const authForm = document.querySelector('.auth-form.active'); 565 authCard.insertBefore(msgDiv, authForm); 566 567 setTimeout(() => { 568 msgDiv.remove(); 569 }, 5000); 570} 571 572/** 573 * Tab switching between Login and Register forms 574 */ 575document.querySelectorAll('.auth-tabs .tab-btn').forEach(btn => { 576 btn.addEventListener('click', function() { 577 const tab = this.dataset.tab; 578 579 // Update active tab button 580 document.querySelectorAll('.auth-tabs .tab-btn').forEach(b => b.classList.remove('active')); 581 this.classList.add('active'); 582 583 // Show corresponding form 584 document.querySelectorAll('.auth-form').forEach(form => form.classList.remove('active')); 585 const targetForm = document.getElementById(tab === 'login' ? 'loginForm' : 'registerForm'); 586 if (targetForm) { 587 targetForm.classList.add('active'); 588 } 589 590 // Clear any existing messages 591 const existingMsg = document.querySelector('.auth-message'); 592 if (existingMsg) { 593 existingMsg.remove(); 594 } 595 }); 596});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.