301 lines
8.8 KiB
TypeScript
301 lines
8.8 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
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
|
|
}
|