Files
BarangaySystem/resources/js/composables/Core/useSessionGuard.js
2026-06-06 18:43:00 +08:00

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,
};
}