// FitPub - Authentication Management /** * Authentication utilities for managing JWT tokens and user sessions */ const FitPubAuth = { /** * Get the stored JWT token * @returns {string|null} JWT token or null if not found */ getToken: function() { return localStorage.getItem('jwtToken'); }, /** * Store JWT token * @param {string} token - JWT token to store */ setToken: function(token) { localStorage.setItem('jwtToken', token); }, /** * Remove stored JWT token */ removeToken: function() { localStorage.removeItem('jwtToken'); localStorage.removeItem('username'); }, /** * Get the stored username * @returns {string|null} Username or null if not found */ getUsername: function() { return localStorage.getItem('username'); }, /** * Store username * @param {string} username - Username to store */ setUsername: function(username) { localStorage.setItem('username', username); }, /** * Check if user is authenticated * @returns {boolean} True if authenticated, false otherwise */ isAuthenticated: function() { const token = this.getToken(); if (!token) { return false; } // Check if token is expired try { const payload = this.parseJwt(token); const now = Math.floor(Date.now() / 1000); if (payload.exp && payload.exp < now) { // Token expired, remove it this.removeToken(); return false; } return true; } catch (e) { console.error('Error parsing JWT:', e); this.removeToken(); return false; } }, /** * Parse JWT token to extract payload * @param {string} token - JWT token * @returns {object} Decoded payload */ parseJwt: function(token) { try { const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = decodeURIComponent( atob(base64).split('').map(function(c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); }).join('') ); return JSON.parse(jsonPayload); } catch (e) { console.error('Error parsing JWT:', e); throw e; } }, /** * Get time until token expiration * @returns {number} Seconds until expiration, or 0 if expired/invalid */ getTokenExpirationTime: function() { const token = this.getToken(); if (!token) { return 0; } try { const payload = this.parseJwt(token); const now = Math.floor(Date.now() / 1000); if (payload.exp) { return Math.max(0, payload.exp - now); } return 0; } catch (e) { return 0; } }, /** * Logout user */ logout: function() { this.removeToken(); window.location.href = '/login'; }, /** * Make an authenticated API request * @param {string} url - API endpoint URL * @param {object} options - Fetch options * @returns {Promise} Fetch response */ authenticatedFetch: async function(url, options = {}) { const token = this.getToken(); if (!token) { throw new Error('No authentication token found'); } // Add Authorization header const headers = { ...options.headers, 'Authorization': `Bearer ${token}`, }; // If body is an object, set Content-Type to JSON if (options.body && typeof options.body === 'object') { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(options.body); } const response = await fetch(url, { ...options, headers }); // If unauthorized, redirect to login if (response.status === 401) { this.removeToken(); window.location.href = '/login'; throw new Error('Authentication failed'); } return response; }, /** * Initialize authentication checks and setup */ init: function() { // Update navigation UI based on auth status this.updateNavigationUI(); // Check registration status and update UI this.checkRegistrationStatus(); // Check authentication status on page load this.checkAuthStatus(); // Set up session expiration warning this.setupExpirationWarning(); }, /** * Check if registration is enabled and update navigation UI */ checkRegistrationStatus: async function() { try { const response = await fetch('/api/auth/registration-status'); const data = await response.json(); if (!data.enabled) { // Hide registration link in navigation const registerLinks = document.querySelectorAll('a[href="/register"]'); registerLinks.forEach(link => { const parent = link.parentElement; if (parent && parent.tagName === 'LI') { parent.style.display = 'none'; } }); } } catch (error) { console.error('Error checking registration status:', error); // Continue without hiding registration link if check fails } }, /** * Update navigation UI based on authentication status */ updateNavigationUI: function() { const authUserMenu = document.getElementById('authUserMenu'); const guestMenu = document.getElementById('guestMenu'); const usernameDisplay = document.getElementById('usernameDisplay'); const myActivitiesLink = document.getElementById('myActivitiesLink'); const uploadLink = document.getElementById('uploadLink'); const analyticsLink = document.getElementById('analyticsLink'); const heatmapLink = document.getElementById('heatmapLink'); const notificationsBell = document.getElementById('notificationsBell'); if (this.isAuthenticated()) { // Show authenticated menu, hide guest menu if (authUserMenu) { authUserMenu.classList.remove('d-none'); } if (guestMenu) { guestMenu.style.display = 'none'; } // Show authenticated navigation links if (myActivitiesLink) { myActivitiesLink.style.display = ''; myActivitiesLink.parentElement.style.display = ''; } if (uploadLink) { uploadLink.style.display = ''; uploadLink.parentElement.style.display = ''; } if (analyticsLink) { analyticsLink.style.display = ''; analyticsLink.parentElement.style.display = ''; } if (heatmapLink) { heatmapLink.style.display = ''; heatmapLink.parentElement.style.display = ''; } // Show notifications bell if (notificationsBell) { notificationsBell.classList.remove('d-none'); } // Display username const username = this.getUsername(); if (usernameDisplay && username) { usernameDisplay.textContent = username; } // Start polling for unread notifications this.startNotificationPolling(); } else { // Show guest menu, hide authenticated menu if (authUserMenu) { authUserMenu.classList.add('d-none'); } if (notificationsBell) { notificationsBell.classList.add('d-none'); } if (guestMenu) { guestMenu.style.display = ''; } // Hide authenticated navigation links if (myActivitiesLink) { myActivitiesLink.style.display = 'none'; myActivitiesLink.parentElement.style.display = 'none'; } if (uploadLink) { uploadLink.style.display = 'none'; uploadLink.parentElement.style.display = 'none'; } if (analyticsLink) { analyticsLink.style.display = 'none'; analyticsLink.parentElement.style.display = 'none'; } if (heatmapLink) { heatmapLink.style.display = 'none'; heatmapLink.parentElement.style.display = 'none'; } } }, /** * Check authentication status and handle accordingly */ checkAuthStatus: function() { const currentPath = window.location.pathname; const publicPaths = ['/', '/login', '/register', '/timeline']; // Skip check for public paths if (publicPaths.includes(currentPath)) { return; } // Activity detail pages are public (for viewing public activities) // Pattern: /activities/{uuid} if (currentPath.startsWith('/activities/') && currentPath.split('/').length === 3) { return; } // Check if authenticated if (!this.isAuthenticated()) { // Redirect to login for protected pages window.location.href = '/login?redirect=' + encodeURIComponent(currentPath); } }, /** * Set up warning for session expiration */ setupExpirationWarning: function() { const token = this.getToken(); if (!token) { return; } const expirationTime = this.getTokenExpirationTime(); if (expirationTime > 0) { // Warn 5 minutes before expiration const warningTime = Math.max(0, (expirationTime - 300) * 1000); setTimeout(() => { if (this.isAuthenticated()) { this.showExpirationWarning(); } }, warningTime); } }, /** * Show session expiration warning */ showExpirationWarning: function() { if (window.FitPub && window.FitPub.showAlert) { window.FitPub.showAlert( 'Your session will expire soon. Please save your work.', 'warning' ); } else { console.warn('Session expiring soon'); } }, /** * Refresh the current page with authentication */ refreshPage: function() { window.location.reload(); }, /** * Start polling for unread notifications */ notificationPollInterval: null, startNotificationPolling: function() { // Stop any existing polling this.stopNotificationPolling(); // Initial fetch this.updateUnreadNotificationCount(); // Poll every 30 seconds this.notificationPollInterval = setInterval(() => { this.updateUnreadNotificationCount(); }, 30000); }, stopNotificationPolling: function() { if (this.notificationPollInterval) { clearInterval(this.notificationPollInterval); this.notificationPollInterval = null; } }, async updateUnreadNotificationCount() { try { const response = await this.authenticatedFetch('/api/notifications/unread/count'); if (response.ok) { const data = await response.json(); const badge = document.getElementById('navNotificationCount'); if (badge) { if (data.count > 0) { badge.textContent = data.count > 99 ? '99+' : data.count; badge.style.display = 'inline-block'; } else { badge.style.display = 'none'; } } } } catch (error) { // Silently fail - don't spam console with errors console.debug('Failed to fetch notification count:', error); } } }; // Initialize authentication on page load document.addEventListener('DOMContentLoaded', function() { FitPubAuth.init(); }); // Make available globally window.FitPubAuth = FitPubAuth;