/** * Session Guard Composable * * Connects to /sse/stream to receive real-time updates for session validity, * user notes, and executive commands. * When the session is gone, it clears all local auth state and reloads * the page so the user lands on the login screen. * * FALLBACK: If SSE is unavailable (insecure context, EventSource errors), * automatically switches to a Web Worker that polls /get/isloggedin and * /get/isExec at regular intervals. */ import { onMounted, onUnmounted } from 'vue'; import { useUserStore } from '../../stores/user.js'; import { usePosStore } from '../../stores/pos.js'; import { useUIStore } from '../../stores/ui.js'; import { useProductStore } from '../../stores/product.js'; const GUARD_ACTIVE_KEY = 'session_guard_active'; // Singleton guard — only one EventSource across the entire SPA let eventSource = null; let pollingWorker = null; let guardStarted = false; let sseFailCount = 0; const MAX_SSE_RETRIES = 1; /** * Clear all local authentication artifacts and force reload to login. * Exported so the axios interceptor can call it without duplicating logic. */ export function handleSessionExpired() { console.warn('[SessionGuard] Session expired — clearing local state and redirecting to login.'); // Clear sessionStorage auth data sessionStorage.removeItem('user_acct_type'); sessionStorage.removeItem('currentUser'); sessionStorage.removeItem(GUARD_ACTIVE_KEY); // Clear any localStorage auth tokens if they exist localStorage.removeItem('auth_token'); localStorage.removeItem('jwt_token'); localStorage.removeItem('token'); // Stop the connection stopGuard(); // Force full page reload to login window.location.href = '/login'; } /** * Start the SSE connection (idempotent — safe to call multiple times). * Exported so callers can re-trigger the guard after the user's account type * is confirmed (e.g., right after fetchCurrentUser resolves with a real user). */ export function startGuard() { if (guardStarted) return; // Don't connect on login/public pages — would cause redirect loops const path = window.location.pathname.toLowerCase(); if (path === '/login' || path === '/sp/login') return; // Don't connect for guests. The guard exists to detect an *expired* // authenticated session — for someone who was never logged in there is // nothing to guard, and connecting would cause /sse/stream to immediately // report isloggedin:false and bounce the visitor to /login. const acctType = sessionStorage.getItem('user_acct_type'); if (!acctType || acctType === 'public') return; guardStarted = true; sseFailCount = 0; sessionStorage.setItem(GUARD_ACTIVE_KEY, '1'); connectSSE(); checkExec(); } /** * Fetch and execute any pending executive command once. */ async function checkExec() { try { const response = await fetch('/get/isExec'); if (response.ok) { const command = await response.text(); if (command && command.trim()) { const userStore = useUserStore(); userStore.executeCommand(command); } } } catch (e) { console.warn('[SessionGuard] Failed to fetch initial exec command:', e); } } // Tracking the current build/app version let currentVersion = null; function connectSSE() { if (eventSource) { eventSource.close(); } // Initialize current version from initial page if not yet set if (!currentVersion) { const el = document.getElementById('main-body'); if (el && el.dataset.page) { try { const pageData = JSON.parse(el.dataset.page); currentVersion = pageData.version; } catch (e) {} } } // Check if EventSource is available at all if (typeof EventSource === 'undefined') { console.warn('[SessionGuard] EventSource not available, switching to Web Worker polling fallback.'); startPollingFallback(); return; } try { eventSource = new EventSource('/sse/stream'); } catch (e) { console.warn('[SessionGuard] Failed to create EventSource, switching to Web Worker polling fallback.', e); startPollingFallback(); return; } eventSource.onmessage = (event) => { // Reset fail count on successful message sseFailCount = 0; try { const data = JSON.parse(event.data); if (data.isloggedin === false) { handleSessionExpired(); return; } // Reload page if a new asset version is detected if (data.version && currentVersion && data.version !== currentVersion) { console.warn('[SessionGuard] New asset version detected:', data.version, 'Local version:', currentVersion, '. Reloading...'); window.location.reload(); return; } // If version hasn't been set yet (shouldn't happen with the check above, but for safety) if (data.version && !currentVersion) { currentVersion = data.version; } const userStore = useUserStore(); const posStore = usePosStore(); const uiStore = useUIStore(); const productStore = useProductStore(); if (data.notes) { userStore.setNotes(data.notes); } if (data.exec) { userStore.executeCommand(data.exec); } if (data.disabled_pages) { uiStore.syncDisabledPages(data.disabled_pages); } if (data.pos_stats || data.customers || data.inventory_deltas) { posStore.syncFromSSE(data); } if (data.products_market || data.inventory_deltas) { productStore.syncFromSSE(data); } } catch (e) { console.error('[SessionGuard] Failed to parse SSE data:', e); } }; eventSource.onerror = (error) => { sseFailCount++; console.warn('[SessionGuard] SSE connection error (' + sseFailCount + '/' + (MAX_SSE_RETRIES + 1) + ').', error); if (sseFailCount > MAX_SSE_RETRIES) { // SSE is not working reliably — switch to Web Worker polling console.warn('[SessionGuard] SSE unavailable after retries, switching to Web Worker polling fallback.'); if (eventSource) { eventSource.close(); eventSource = null; } startPollingFallback(); return; } if (eventSource && eventSource.readyState === EventSource.CLOSED) { // One-time check if we're actually logged out (401) fetch('/get/isloggedin').then(res => { if (res.status === 401 || res.status === 419) { handleSessionExpired(); } }).catch(() => {}); } }; } /** * Start the Web Worker polling fallback. * Used when SSE (EventSource) is unavailable or keeps failing. */ function startPollingFallback() { if (pollingWorker) return; // Check Web Worker support if (typeof Worker === 'undefined') { console.error('[SessionGuard] Neither EventSource nor Web Workers available. Falling back to setInterval polling on main thread.'); startMainThreadFallback(); return; } try { pollingWorker = new Worker( new URL('../../workers/session-guard-worker.js', import.meta.url), { type: 'classic' } ); } catch (e) { console.warn('[SessionGuard] Failed to create Web Worker, falling back to main-thread polling.', e); startMainThreadFallback(); return; } console.info('[SessionGuard] Web Worker polling fallback started.'); pollingWorker.onmessage = (event) => { const { type, data } = event.data || {}; if (type === 'session') { if (data && data.isloggedin === false) { handleSessionExpired(); return; } } if (type === 'exec') { if (data && typeof data === 'string' && data.trim()) { const userStore = useUserStore(); userStore.executeCommand(data); } } }; pollingWorker.onerror = (err) => { console.error('[SessionGuard] Web Worker error:', err); // If the worker itself fails, fall back to main thread stopPollingWorker(); startMainThreadFallback(); }; // Send start command (worker also auto-starts, but this is explicit) pollingWorker.postMessage({ type: 'start' }); } /** * Last-resort fallback: poll on the main thread using setInterval. * Used when neither SSE nor Web Workers are available. */ let mainThreadLoginTimer = null; let mainThreadExecTimer = null; function startMainThreadFallback() { if (mainThreadLoginTimer) return; console.info('[SessionGuard] Main-thread polling fallback started.'); const checkLoginMain = async () => { try { const res = await fetch('/get/isloggedin'); if (res.ok) { const data = await res.json(); if (data.isloggedin === false) { handleSessionExpired(); } } else if (res.status === 401 || res.status === 419) { handleSessionExpired(); } } catch (e) { /* skip */ } }; const checkExecMain = async () => { try { const res = await fetch('/get/isExec'); if (res.ok) { const text = await res.text(); if (text && text.trim()) { const userStore = useUserStore(); userStore.executeCommand(text); } } } catch (e) { /* skip */ } }; // Run immediately checkLoginMain(); checkExecMain(); mainThreadLoginTimer = setInterval(checkLoginMain, 10000); mainThreadExecTimer = setInterval(checkExecMain, 30000); } function stopMainThreadFallback() { if (mainThreadLoginTimer) { clearInterval(mainThreadLoginTimer); mainThreadLoginTimer = null; } if (mainThreadExecTimer) { clearInterval(mainThreadExecTimer); mainThreadExecTimer = null; } } function stopPollingWorker() { if (pollingWorker) { try { pollingWorker.postMessage({ type: 'stop' }); } catch (e) {} pollingWorker.terminate(); pollingWorker = null; } } /** * Stop the SSE connection and any fallback mechanisms. */ function stopGuard() { if (eventSource) { eventSource.close(); eventSource = null; } stopPollingWorker(); stopMainThreadFallback(); guardStarted = false; sseFailCount = 0; sessionStorage.removeItem(GUARD_ACTIVE_KEY); } /** * Vue composable to activate the session guard within a component lifecycle. * Typically used in the root App or a layout component. */ export function useSessionGuard() { onMounted(() => { startGuard(); }); onUnmounted(() => { // In SPA root this won't fire, but be tidy for sub-components stopGuard(); }); return { startGuard, stopGuard, }; }