/** * CSRF Token Auto-Refresh System * * Automatically refreshes CSRF tokens before they expire to prevent * form submission errors when users keep forms open for extended periods. * * Features: * - Automatic token refresh every 105 minutes (15 minutes before expiry) * - Support for both regular forms and LiveComponents * - Visual feedback when tokens are refreshed * - Graceful error handling with fallback strategies * - Page visibility optimization (pause when tab is inactive) * - Multiple form support * * Usage: * import { CsrfAutoRefresh } from './modules/csrf-auto-refresh.js'; * * // Initialize for contact form * const csrfRefresh = new CsrfAutoRefresh({ * formId: 'contact_form', * refreshInterval: 105 * 60 * 1000 // 105 minutes * }); * * // Initialize for a LiveComponent * const liveRefresh = new CsrfAutoRefresh({ * formId: 'counter:demo', // Component ID * refreshInterval: 105 * 60 * 1000 * }); * * // Or auto-detect all forms and LiveComponents with CSRF tokens * CsrfAutoRefresh.initializeAll(); */ export class CsrfAutoRefresh { /** * Default configuration */ static DEFAULT_CONFIG = { formId: 'contact_form', refreshInterval: 105 * 60 * 1000, // 105 minutes (15 min before 2h expiry) apiEndpoint: '/api/csrf/refresh', tokenSelector: 'input[name="_token"]', formIdSelector: 'input[name="_form_id"]', enableVisualFeedback: false, // Disabled to hide browser notifications enableConsoleLogging: true, pauseWhenHidden: true, // Pause refresh when tab is not visible maxRetries: 3, retryDelay: 5000 // 5 seconds }; /** * @param {Object} config Configuration options */ constructor(config = {}) { this.config = { ...CsrfAutoRefresh.DEFAULT_CONFIG, ...config }; this.intervalId = null; this.isActive = false; this.retryCount = 0; this.lastRefreshTime = null; // Track page visibility for optimization this.isPageVisible = !document.hidden; this.log('CSRF Auto-Refresh initialized', this.config); // Setup event listeners this.setupEventListeners(); // Start auto-refresh if page is visible if (this.isPageVisible) { this.start(); } } /** * Setup event listeners for page visibility and unload */ setupEventListeners() { if (this.config.pauseWhenHidden) { document.addEventListener('visibilitychange', () => { this.isPageVisible = !document.hidden; if (this.isPageVisible) { this.log('Page became visible, resuming CSRF refresh'); this.start(); } else { this.log('Page became hidden, pausing CSRF refresh'); this.stop(); } }); } // Cleanup on page unload window.addEventListener('beforeunload', () => { this.stop(); }); } /** * Start the auto-refresh timer */ start() { if (this.isActive) { this.log('Auto-refresh already active'); return; } this.isActive = true; // Set up interval for token refresh this.intervalId = setInterval(() => { this.refreshToken(); }, this.config.refreshInterval); this.log(`Auto-refresh started. Next refresh in ${this.config.refreshInterval / 1000 / 60} minutes`); // Show visual feedback if enabled if (this.config.enableVisualFeedback) { // this.showStatusMessage('CSRF protection enabled - tokens will refresh automatically', 'info'); } } /** * Stop the auto-refresh timer */ stop() { if (!this.isActive) { return; } if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.isActive = false; this.log('Auto-refresh stopped'); } /** * Refresh the CSRF token via API call */ async refreshToken() { if (!this.isPageVisible && this.config.pauseWhenHidden) { this.log('Page not visible, skipping refresh'); return; } try { this.log('Refreshing CSRF token...'); const response = await fetch(`${this.config.apiEndpoint}?form_id=${encodeURIComponent(this.config.formId)}`, { method: 'GET', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (!data.success || !data.token) { throw new Error(data.error || 'Invalid response from server'); } // Update token in all matching forms this.updateTokenInForms(data.token); this.lastRefreshTime = new Date(); this.retryCount = 0; // Reset retry count on success this.log('CSRF token refreshed successfully', { token: data.token.substring(0, 8) + '...', formId: data.form_id, expiresIn: data.expires_in }); // Show visual feedback if (this.config.enableVisualFeedback) { // this.showStatusMessage('Security token refreshed', 'success'); } } catch (error) { this.handleRefreshError(error); } } /** * Update CSRF token in all forms on the page * Supports both regular forms and LiveComponent data-csrf-token attributes */ updateTokenInForms(newToken) { let updatedCount = 0; // Update regular form input tokens const tokenInputs = document.querySelectorAll(this.config.tokenSelector); tokenInputs.forEach(input => { // Check if this token belongs to our form const form = input.closest('form'); if (form) { const formIdInput = form.querySelector(this.config.formIdSelector); if (formIdInput && formIdInput.value === this.config.formId) { input.value = newToken; updatedCount++; } } }); // Update LiveComponent data-csrf-token attributes // LiveComponents use form ID format: "livecomponent:{componentId}" const liveComponentFormId = 'livecomponent:' + this.config.formId.replace(/^livecomponent:/, ''); const liveComponents = document.querySelectorAll('[data-live-component][data-csrf-token]'); liveComponents.forEach(component => { // Check if this component uses our form ID const componentId = component.dataset.liveComponent; const expectedFormId = 'livecomponent:' + componentId; if (expectedFormId === liveComponentFormId || this.config.formId === componentId) { component.dataset.csrfToken = newToken; updatedCount++; this.log(`Updated LiveComponent token: ${componentId}`); } }); this.log(`Updated ${updatedCount} token(s) (forms + LiveComponents)`); if (updatedCount === 0) { console.warn('CsrfAutoRefresh: No tokens found to update. Check your selectors and formId.'); } return updatedCount; } /** * Handle refresh errors with retry logic */ handleRefreshError(error) { this.retryCount++; console.error('CSRF token refresh failed:', error); if (this.retryCount <= this.config.maxRetries) { this.log(`Retrying in ${this.config.retryDelay / 1000}s (attempt ${this.retryCount}/${this.config.maxRetries})`); setTimeout(() => { this.refreshToken(); }, this.config.retryDelay); // Show visual feedback for retry if (this.config.enableVisualFeedback) { // this.showStatusMessage(`Token refresh failed, retrying... (${this.retryCount}/${this.config.maxRetries})`, 'warning'); } } else { // Max retries reached this.log('Max retries reached, stopping auto-refresh'); this.stop(); if (this.config.enableVisualFeedback) { // this.showStatusMessage('Token refresh failed. Please refresh the page if you encounter errors.', 'error'); } } } /** * Show visual status message to user */ showStatusMessage(message, type = 'info') { // Create or update status element let statusEl = document.getElementById('csrf-status-message'); if (!statusEl) { statusEl = document.createElement('div'); statusEl.id = 'csrf-status-message'; statusEl.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 12px 16px; border-radius: 6px; font-size: 14px; z-index: 10000; max-width: 300px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); transition: opacity 0.3s ease; `; document.body.appendChild(statusEl); } // Set message and styling based on type statusEl.textContent = message; statusEl.className = `csrf-status-${type}`; // Style based on type const styles = { info: 'background: #e3f2fd; color: #1976d2; border-left: 4px solid #2196f3;', success: 'background: #e8f5e8; color: #2e7d32; border-left: 4px solid #4caf50;', warning: 'background: #fff3e0; color: #f57c00; border-left: 4px solid #ff9800;', error: 'background: #ffebee; color: #d32f2f; border-left: 4px solid #f44336;' }; statusEl.style.cssText += styles[type] || styles.info; statusEl.style.opacity = '1'; // Auto-hide after delay (except for errors) if (type !== 'error') { setTimeout(() => { if (statusEl) { statusEl.style.opacity = '0'; setTimeout(() => { if (statusEl && statusEl.parentNode) { statusEl.parentNode.removeChild(statusEl); } }, 300); } }, type === 'success' ? 3000 : 5000); } } /** * Log messages if console logging is enabled */ log(message, data = null) { if (this.config.enableConsoleLogging) { const timestamp = new Date().toLocaleTimeString(); if (data) { console.log(`[${timestamp}] CsrfAutoRefresh: ${message}`, data); } else { console.log(`[${timestamp}] CsrfAutoRefresh: ${message}`); } } } /** * Get current status information */ getStatus() { return { isActive: this.isActive, formId: this.config.formId, lastRefreshTime: this.lastRefreshTime, retryCount: this.retryCount, isPageVisible: this.isPageVisible, nextRefreshIn: this.isActive ? Math.max(0, this.config.refreshInterval - (Date.now() - (this.lastRefreshTime?.getTime() || Date.now()))) : null }; } /** * Manual token refresh (useful for debugging) */ async manualRefresh() { this.log('Manual refresh triggered'); await this.refreshToken(); } /** * Static method to initialize auto-refresh for all forms with CSRF tokens * Supports both regular forms and LiveComponents */ static initializeAll() { const formIds = new Set(); // Collect unique form IDs from regular forms const tokenInputs = document.querySelectorAll('input[name="_token"]'); tokenInputs.forEach(input => { const form = input.closest('form'); if (form) { const formIdInput = form.querySelector('input[name="_form_id"]'); if (formIdInput && formIdInput.value) { formIds.add(formIdInput.value); } } }); // Collect unique component IDs from LiveComponents const liveComponents = document.querySelectorAll('[data-live-component][data-csrf-token]'); liveComponents.forEach(component => { const componentId = component.dataset.liveComponent; if (componentId) { // Use the component ID directly (without "livecomponent:" prefix for config) formIds.add(componentId); } }); // Initialize auto-refresh for each unique form/component ID const instances = []; formIds.forEach(formId => { const instance = new CsrfAutoRefresh({ formId }); instances.push(instance); }); console.log(`CsrfAutoRefresh: Initialized for ${instances.length} forms/components:`, Array.from(formIds)); return instances; } } // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { CsrfAutoRefresh.initializeAll(); }); } else { CsrfAutoRefresh.initializeAll(); } // Export for manual usage export default CsrfAutoRefresh;