/** * Client-side verification state management with circuit breaker pattern * Prevents endless reload loops on mobile browsers */ export interface VerificationAttempt { token: string; attempts: number; lastAttempt: number; maxAttempts: number; status: 'pending' | 'success' | 'failed' | 'blocked'; errors: string[]; } const STORAGE_KEY = 'email_verification_state'; const MAX_ATTEMPTS_DEFAULT = 3; const ATTEMPT_WINDOW = 5 * 60 * 1000; // 5 minutes const CIRCUIT_BREAKER_TIMEOUT = 10 * 60 * 1000; // 10 minutes /** * Get verification state for a token */ export function getVerificationState(token: string): VerificationAttempt | null { if (typeof window === 'undefined' || !token) return null; try { const stored = sessionStorage.getItem(`${STORAGE_KEY}_${token.substring(0, 10)}`); if (!stored) return null; const state = JSON.parse(stored) as VerificationAttempt; // Check if circuit breaker timeout has passed const now = Date.now(); if (state.status === 'blocked' && (now - state.lastAttempt) > CIRCUIT_BREAKER_TIMEOUT) { console.log('[verification-state] Circuit breaker timeout passed, resetting state'); clearVerificationState(token); return null; } return state; } catch (error) { console.warn('[verification-state] Failed to parse stored state:', error); return null; } } /** * Initialize or update verification state */ export function initVerificationState(token: string, maxAttempts: number = MAX_ATTEMPTS_DEFAULT): VerificationAttempt { if (typeof window === 'undefined' || !token) { throw new Error('Cannot initialize verification state: no window or token'); } const existing = getVerificationState(token); if (existing) { return existing; } const state: VerificationAttempt = { token, attempts: 0, lastAttempt: 0, maxAttempts, status: 'pending', errors: [] }; try { sessionStorage.setItem(`${STORAGE_KEY}_${token.substring(0, 10)}`, JSON.stringify(state)); console.log('[verification-state] Initialized verification state for token'); return state; } catch (error) { console.error('[verification-state] Failed to save state:', error); return state; } } /** * Record a verification attempt */ export function recordAttempt(token: string, success: boolean = false, error?: string): VerificationAttempt { if (typeof window === 'undefined' || !token) { throw new Error('Cannot record attempt: no window or token'); } const state = getVerificationState(token) || initVerificationState(token); const now = Date.now(); // Check if we're within the attempt window if (state.lastAttempt > 0 && (now - state.lastAttempt) > ATTEMPT_WINDOW) { console.log('[verification-state] Attempt window expired, resetting counter'); state.attempts = 0; state.errors = []; } state.attempts++; state.lastAttempt = now; if (success) { state.status = 'success'; console.log('[verification-state] Verification successful, clearing state'); // Don't clear immediately - let the navigation complete first setTimeout(() => clearVerificationState(token), 1000); } else { if (error) { state.errors.push(error); } if (state.attempts >= state.maxAttempts) { state.status = 'blocked'; console.log(`[verification-state] Maximum attempts (${state.maxAttempts}) reached, blocking further attempts`); } else { state.status = 'failed'; console.log(`[verification-state] Attempt ${state.attempts}/${state.maxAttempts} failed`); } } try { sessionStorage.setItem(`${STORAGE_KEY}_${token.substring(0, 10)}`, JSON.stringify(state)); } catch (error) { console.error('[verification-state] Failed to update state:', error); } return state; } /** * Check if verification should be blocked */ export function shouldBlockVerification(token: string): boolean { if (typeof window === 'undefined' || !token) return false; const state = getVerificationState(token); if (!state) return false; if (state.status === 'blocked') { const timeRemaining = CIRCUIT_BREAKER_TIMEOUT - (Date.now() - state.lastAttempt); if (timeRemaining > 0) { console.log(`[verification-state] Verification blocked for ${Math.ceil(timeRemaining / 1000 / 60)} more minutes`); return true; } } return state.status === 'success' || (state.attempts >= state.maxAttempts && state.status !== 'pending'); } /** * Clear verification state for a token */ export function clearVerificationState(token: string): void { if (typeof window === 'undefined' || !token) return; try { sessionStorage.removeItem(`${STORAGE_KEY}_${token.substring(0, 10)}`); console.log('[verification-state] Cleared verification state'); } catch (error) { console.warn('[verification-state] Failed to clear state:', error); } } /** * Get user-friendly status message */ export function getStatusMessage(state: VerificationAttempt | null): string { if (!state) return ''; switch (state.status) { case 'pending': return ''; case 'success': return 'Email verified successfully!'; case 'failed': if (state.attempts === 1) { return 'Verification failed. Retrying...'; } return `Verification failed (${state.attempts}/${state.maxAttempts} attempts). ${state.maxAttempts - state.attempts} attempts remaining.`; case 'blocked': const timeRemaining = Math.ceil((CIRCUIT_BREAKER_TIMEOUT - (Date.now() - state.lastAttempt)) / 1000 / 60); return `Too many failed attempts. Please wait ${timeRemaining} minutes before trying again, or contact support.`; default: return ''; } } /** * Progressive navigation with fallbacks for mobile browsers */ export async function navigateWithFallback(url: string, options: { replace?: boolean } = {}): Promise { if (typeof window === 'undefined') return false; console.log(`[verification-state] Attempting navigation to: ${url}`); try { // Method 1: Use Nuxt navigateTo if (typeof navigateTo === 'function') { console.log('[verification-state] Using navigateTo'); await navigateTo(url, options); return true; } } catch (error) { console.warn('[verification-state] navigateTo failed:', error); } try { // Method 2: Use Vue Router (if available) const nuxtApp = (window as any)?.$nuxt; if (nuxtApp?.$router) { console.log('[verification-state] Using Vue Router'); if (options.replace) { await nuxtApp.$router.replace(url); } else { await nuxtApp.$router.push(url); } return true; } } catch (error) { console.warn('[verification-state] Vue Router failed:', error); } // Method 3: Direct window.location (mobile fallback) console.log('[verification-state] Using window.location fallback'); if (options.replace) { window.location.replace(url); } else { window.location.href = url; } return true; } /** * Mobile-specific delay before navigation to ensure stability */ export function getMobileNavigationDelay(): number { if (typeof window === 'undefined') return 0; // Detect mobile browsers const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isSafari = /Safari/i.test(navigator.userAgent) && !/Chrome/i.test(navigator.userAgent); if (isMobile && isSafari) { return 500; // Extra delay for Safari on iOS } else if (isMobile) { return 300; // Standard mobile delay } return 100; // Minimal delay for desktop } /** * Clean up all expired verification states */ export function cleanupExpiredStates(): void { if (typeof window === 'undefined') return; try { const now = Date.now(); const keysToRemove: string[] = []; for (let i = 0; i < sessionStorage.length; i++) { const key = sessionStorage.key(i); if (!key?.startsWith(STORAGE_KEY)) continue; try { const stored = sessionStorage.getItem(key); if (!stored) continue; const state = JSON.parse(stored) as VerificationAttempt; // Remove states older than circuit breaker timeout if ((now - state.lastAttempt) > CIRCUIT_BREAKER_TIMEOUT) { keysToRemove.push(key); } } catch (error) { // Remove invalid stored data keysToRemove.push(key); } } keysToRemove.forEach(key => { sessionStorage.removeItem(key); }); if (keysToRemove.length > 0) { console.log(`[verification-state] Cleaned up ${keysToRemove.length} expired verification states`); } } catch (error) { console.warn('[verification-state] Failed to cleanup expired states:', error); } } // Auto-cleanup on page load if (typeof window !== 'undefined') { // Clean up immediately cleanupExpiredStates(); // Clean up periodically setInterval(cleanupExpiredStates, 5 * 60 * 1000); // Every 5 minutes }