361 lines
10 KiB
JavaScript
361 lines
10 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|
|
}
|