monacousa-portal/utils/verification-state.ts

301 lines
8.8 KiB
TypeScript
Raw Normal View History

/**
* 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
}