/** * AuthManager - Handles user authentication state * * Responsibilities: * - Fetch and cache authentication state from /auth/status * - Store auth data in sessionStorage to reduce API requests * - Invalidate cache when WordPress cookie changes * - Provide auth data through class properties * - Emit events for auth state changes */ class AuthManager { constructor() { this.initialized = false; this.isAuthenticating = false; this.authenticated = false; this.user = false; this.nonces = {}; this.subscribers = new Set(); this.storageKey = `${jvbBase.base}auth_state`; this.cacheMetaKey = `${jvbBase.base}auth_meta`; this.cacheExpiry = 5 * 60 * 1000; // 5 minutes this.init(); } /** * Initialize authentication */ async init() { if (this.isAuthenticating) { return this.ready(); } this.isAuthenticating = true; try { const cached = this.getCachedAuth(); if (cached) { this.setAuthData(cached); this.initialized = true; this.isAuthenticating = false; this.notify('auth-loaded', { fromCache: true }); return; } await this.fetchAuth(); } catch (error) { console.error('Failed to initialize auth:', error); this.clearAuthData(); this.initialized = true; this.isAuthenticating = false; this.notify('auth-error', { error }); } } /** * Refresh nonce if authentication fails */ async refreshNonce(action = 'wp_rest') { try { await this.fetchAuth(); return this.getNonce(action); } catch (error) { console.error('Failed to refresh nonce:', error); return null; } } /** * Fetch with automatic nonce refresh on auth failure * Use this for all authenticated API requests */ async fetch(url, options = {}) { const attempt = async (retryCount = 0) => { const headers = { 'Content-Type': 'application/json', ...options.headers, 'X-WP-Nonce': this.getNonce() }; const response = await fetch(url, { ...options, credentials: 'same-origin', headers }); if ((response.status === 403 || response.status === 401) && retryCount === 0) { const result = await response.clone().json(); if (result.code === 'rest_cookie_invalid_nonce' || result.message?.includes('Cookie check')) { console.log('Nonce invalid, refreshing auth...'); await this.refresh(); return attempt(1); } } return response; }; return attempt(); } /** * Fetch authentication status from API */ async fetchAuth() { const response = await fetch(`${jvbSettings.api}auth/status`, { method: 'GET', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error('Auth check failed'); } const authData = await response.json(); // Check if session changed (e.g., logout in another tab) const cachedMeta = sessionStorage.getItem(this.cacheMetaKey); if (cachedMeta) { const meta = JSON.parse(cachedMeta); if (meta.session_id && meta.session_id !== authData.session_id) { this.clearCachedAuth(); this.notify('session-changed', {}); } } this.cacheAuth(authData); this.setAuthData(authData); this.initialized = true; this.isAuthenticating = false; this.notify('auth-loaded', { fromCache: false }); } /** * Set authentication data */ setAuthData(authData) { this.authenticated = authData.authenticated || false; this.user = authData.user || false; this.nonces = authData.nonces || {}; } /** * Clear authentication data */ clearAuthData() { this.authenticated = false; this.user = null; this.nonces = {}; sessionStorage.removeItem(this.storageKey); sessionStorage.removeItem(this.cacheMetaKey ); } /** * Get cached auth data (only if cookie matches) */ getCachedAuth() { try { const cachedAuth = sessionStorage.getItem(this.storageKey); const cacheMeta = sessionStorage.getItem(this.cacheMetaKey); if (!cachedAuth || !cacheMeta) { return null; } const meta = JSON.parse(cacheMeta); const authData = JSON.parse(cachedAuth); // Time-based expiry (nonce freshness) if (Date.now() - meta.timestamp > this.cacheExpiry) { this.clearCachedAuth(); return null; } // Session changed (login/logout in another tab/window) // We'll verify this on next fetch and clear if mismatched return authData; } catch (error) { console.error('Error reading cached auth:', error); return null; } } /** * Cache auth data in sessionStorage */ cacheAuth(authData) { try { sessionStorage.setItem(this.storageKey, JSON.stringify(authData)); sessionStorage.setItem(this.cacheMetaKey, JSON.stringify({ session_id: authData.session_id || null, timestamp: Date.now() })); } catch (error) { console.error('Error caching auth:', error); } } clearCachedAuth() { sessionStorage.removeItem(this.storageKey); sessionStorage.removeItem(this.cacheMetaKey); } /** * Refresh authentication (force new fetch) */ async refresh() { this.isAuthenticating = true; this.initialized = false; try { await this.fetchAuth(); this.notify('auth-refreshed', {}); } catch (error) { console.error('Failed to refresh auth:', error); this.clearAuthData(); this.initialized = true; this.isAuthenticating = false; this.notify('auth-error', { error }); } } /** * Get nonce for a specific action */ getNonce(action = 'wp_rest') { return this.nonces[action] || ''; } getUser() { return this.user; } isAuthenticated() { return this.authenticated; } /** * Handle successful login (call after login completes) */ async handleLogin(authData = null) { // Clear old cache sessionStorage.removeItem(this.storageKey); sessionStorage.removeItem(this.cacheMetaKey); // If auth data provided, use it directly if (authData) { this.cacheAuth(authData); this.setAuthData(authData); this.initialized = true; this.isAuthenticating = false; this.notify('auth-loaded', { fromCache: false, fromLogin: true }); return; } // Otherwise fetch fresh (for backward compatibility) await this.refresh(); } /** * Handle logout */ handleLogout() { this.clearAuthData(); this.notify('logged-out', {}); } /** * Subscribe to auth events */ subscribe(callback) { this.subscribers.add(callback); // If already initialized, immediately notify if (this.initialized) { callback('auth-loaded', { fromCache: false, immediate: true }); } return () => this.subscribers.delete(callback); } /** * Notify subscribers of events */ notify(event, data) { this.subscribers.forEach(callback => { try { callback(event, data); } catch (error) { console.error('Subscriber error:', error); } }); } /** * Wait for auth to be ready */ ready() { if (this.initialized) { return Promise.resolve(); } return new Promise(resolve => { const unsubscribe = this.subscribe((event) => { if (event === 'auth-loaded' || event === 'auth-error') { unsubscribe(); resolve(); } }); }); } } // Initialize global instance window.auth = new AuthManager();