initial: bootstrap from BukidBountyApp base
This commit is contained in:
360
resources/js/composables/Core/useSessionGuard.js
Normal file
360
resources/js/composables/Core/useSessionGuard.js
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user