initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { UserTypes } from '../../utils/UserTypes.js';
import { useUserStore } from '../../stores/user.js';
// Global reactive state to persist throughout the SPA session
const globalRole = ref(sessionStorage.getItem('user_acct_type') || UserTypes.PUBLIC);
const isFetching = ref(false);
/**
* Fetches the user account type from the server.
* Ensures only one request is made per session.
*/
async function fetchRole() {
if (isFetching.value) return;
// If we already have a specialized role in sessionStorage, don't fetch again
const cached = sessionStorage.getItem('user_acct_type');
if (cached && cached !== UserTypes.PUBLIC) {
return;
}
isFetching.value = true;
try {
const response = await axios.get('/get/user/acct-type');
let acctType = response.data?.acct_type;
// Handle case where acct_type might be an object (Enum serialization)
if (acctType && typeof acctType === 'object' && acctType.value) {
acctType = acctType.value;
}
if (acctType) {
globalRole.value = acctType;
sessionStorage.setItem('user_acct_type', acctType);
}
} catch (error) {
// If 401, we are likely a guest
if (error.response?.status === 401) {
globalRole.value = UserTypes.PUBLIC;
sessionStorage.setItem('user_acct_type', UserTypes.PUBLIC);
} else {
console.warn('Failed to fetch user acct type from server:', error);
}
} finally {
isFetching.value = false;
}
}
// Initial fetch attempt if we don't have a definitive role
if (!sessionStorage.getItem('user_acct_type') || sessionStorage.getItem('user_acct_type') === UserTypes.PUBLIC) {
fetchRole();
}
export function resetRole() {
globalRole.value = UserTypes.PUBLIC;
isFetching.value = false;
sessionStorage.removeItem('user_acct_type');
}
/**
* Composable for managing user roles and permissions in the frontend.
* @param {Object} [user] - Optional user object for legacy support or specific overrides
*/
export function useAuth(user = null) {
const userStore = useUserStore();
// Priority: Explicitly passed user prop > user store > sessionStorage fallback
const currentUser = computed(() => {
if (user?.value ?? user) return user?.value ?? user;
const storeUser = userStore.user;
if (storeUser && Object.keys(storeUser).length > 0) return storeUser;
try {
const stored = sessionStorage.getItem('currentUser');
return stored ? JSON.parse(stored) : null;
} catch (e) {
return null;
}
});
const role = computed(() => {
// Priority 1: User object passed directly or from store
const localRole = userStore.acctType || currentUser.value?.acct_type;
if (localRole) {
if (typeof localRole === 'object' && localRole.value) return localRole.value;
return localRole;
}
// Priority 2: Global fetched role
return globalRole.value;
});
const hasRole = (targetRole) => {
if (Array.isArray(targetRole)) {
return targetRole.some(r => role.value === r);
}
return role.value === targetRole;
};
// Role-specific helpers
const isUltimate = computed(() => role.value === UserTypes.ULTIMATE);
const isSuperOperator = computed(() => role.value === UserTypes.SUPER_OPERATOR);
const isOperator = computed(() => role.value === UserTypes.OPERATOR);
const isCoordinator = computed(() => role.value === UserTypes.COORDINATOR);
const isStoreOwner = computed(() => role.value === UserTypes.STORE_OWNER);
const isStoreManager = computed(() => role.value === UserTypes.STORE_MANAGER);
const isRider = computed(() => role.value === UserTypes.RIDER);
const isSupplier = computed(() => role.value === UserTypes.SUPPLIER);
const isSupplierOverseer = computed(() => role.value === UserTypes.SUPPLIER_OVERSEER);
const isWholesaleBuyer = computed(() => role.value === UserTypes.WHOLESALE_BUYER);
const isAudit = computed(() => role.value === UserTypes.AUDIT);
const isUser = computed(() => role.value === UserTypes.USER);
const isCoopOfficer = computed(() => role.value === UserTypes.COOP_OFFICER);
const isCoopMember = computed(() => role.value === UserTypes.COOP_MEMBER);
const isPOSTerminal = computed(() => role.value === UserTypes.POS_TERMINAL);
const isPublic = computed(() => role.value === UserTypes.PUBLIC || !userStore.isLoggedIn);
const isLoggedIn = computed(() => userStore.isLoggedIn);
return {
user: currentUser,
role,
hasRole,
isUltimate,
isSuperOperator,
isOperator,
isCoordinator,
isStoreOwner,
isStoreManager,
isRider,
isSupplier,
isSupplierOverseer,
isWholesaleBuyer,
isAudit,
isUser,
isCoopOfficer,
isCoopMember,
isPOSTerminal,
isPublic,
isLoggedIn,
UserTypes,
refreshRole: fetchRole,
// Expose the user store for direct access to user data
userStore
};
}

View File

@@ -0,0 +1,309 @@
import { ref, h } from 'vue'
/**
* Global modal visibility state
* Shared across the entire application
*/
const show = ref(false)
/**
* Modal title text
* Can also be `false` to hide the header
* @type {import('vue').Ref<string|boolean>}
*/
const title = ref('')
/**
* Modal body content
* Can be a string, VNode, or Vue component
* @type {import('vue').Ref<any>}
*/
const body = ref(null)
/**
* Modal footer content
* Usually a Vue component, render function, or null
* @type {import('vue').Ref<any>}
*/
const footer = ref(null)
/**
* Global modal composable.
*
* Provides a single shared modal instance
* that can be opened, updated, and closed from anywhere.
*
* Supports string content or Vue components in the body and footer.
*
* Usage:
* ```js
* import { useModal } from '@/composables/useModal'
* import { h } from 'vue'
* import UserDetails from '@/components/UserDetails.vue'
*
* const modal = useModal()
*
* // Open with a string body
* modal.open({ title: 'Notice', body: 'Hello world' })
*
* // Open with a Vue component body
* modal.open({
* title: 'User Details',
* body: h(UserDetails, { userId: 42 })
* })
* ```
*/
export function useModal() {
/** Opens the modal */
function open({ title: t = '', body: b = null, footer: f = null } = {}) {
title.value = t
body.value = b
footer.value = f
show.value = true
}
/** Closes the modal */
function close() {
show.value = false
}
/** Update modal body dynamically */
function setBody(content) {
body.value = content
}
/** Update modal footer dynamically */
function setFooter(content) {
footer.value = content
}
/** Quickly replaces the currently open modal with new content */
function quickDismiss({ title: t = '', body: b = '', footer: f = null, condition = true, onShown = null } = {}) {
if (!condition) return
show.value = false
setTimeout(() => {
open({ title: t, body: b, footer: f })
if (typeof onShown === 'function') onShown()
}, 10)
}
/**
* Displays a modal with navigation buttons for SPA routing.
*
* Each button can pass props to the destination page component.
*
* @param {Object} options
* @param {string} options.title - Modal title
* @param {string|VNode|Component} options.body - Modal body
* @param {Array<Array>} options.buttons - Buttons array:
* [pageName, buttonText, target?, props?]
* Example: ['Users.View', 'View User', 0, { userId: 42 }]
* @param {boolean} [options.condition=true] - Show modal if true
* @param {Function} [options.onShown] - Callback after modal opens
*/
/**
* @param {Object} options
* @param {string} options.title - Modal title
* @param {string|VNode|Component} options.body - Modal body
* @param {Array<Array>} options.buttons - Buttons array:
* [pageName, buttonText, target?, props?]
* @param {Function} options.navigate - The navigate function from useNavigate()
* @param {boolean} [options.condition=true] - Show modal if true
* @param {Function} [options.onShown] - Callback after modal opens
*/
function quickDismissGoto({ title: t = '', body: b = '', buttons = [], navigate = null, condition = true, onShown = null } = {}) {
if (!condition) return
const normalized = Array.isArray(buttons[0]) ? buttons : [buttons]
const FooterComponent = {
render() {
const renderedButtons = normalized.map(btn => {
const page = btn[0]
const text = btn[1] || 'Go to Page'
const props = btn[3] || {}
return h('button', {
class: 'btn btn-primary flex-fill py-2 rounded-3 shadow-sm fw-bold',
onClick: () => {
close()
if (typeof navigate === 'function') navigate({ page, props })
}
}, text)
})
return h('div', { class: 'd-flex w-100 gap-3' }, renderedButtons)
}
}
show.value = false
setTimeout(() => {
open({ title: t, body: b, footer: FooterComponent })
if (typeof onShown === 'function') onShown()
}, 10)
}
/**
* Displays a Yes / No modal with custom callbacks.
*
* @param {Object} options
* @param {string} options.title - Modal title
* @param {string|VNode|Component} options.body - Modal body
* @param {string} options.yesText - Yes button text
* @param {Function} options.onYes - Yes callback
* @param {string} options.noText - No button text
* @param {Function} options.onNo - No callback
* @param {boolean} [options.condition=true] - Show modal if true
* @param {Function} [options.onShown] - Callback after modal opens
*/
function yesNoModal({
title: t = '',
body: b = '',
yesText = 'Yes',
yesClass = 'btn btn-primary w-50 py-2 rounded-3 shadow-sm fw-bold',
onYes = null,
noText = 'No',
noClass = 'btn btn-light w-50 py-2 rounded-3 border fw-bold text-muted',
onNo = null,
condition = true,
onShown = null
} = {}) {
if (!condition) return
const FooterComponent = {
render() {
return h('div', { class: 'd-flex w-100 gap-3' }, [
h('button', { class: noClass, onClick: () => { close(); onNo?.() } }, noText),
h('button', { class: yesClass, onClick: () => { close(); onYes?.() } }, yesText)
])
}
}
show.value = false
setTimeout(() => {
open({ title: t, body: b, footer: FooterComponent })
onShown?.()
}, 10)
}
/**
* Displays a Continue / Cancel modal.
*
* @param {Object} options
* @param {string} options.title - Modal title
* @param {string|VNode|Component} options.body - Modal body content
* @param {Function} options.onContinue - Callback when Continue is clicked
* @param {string} [options.continueText='Continue'] - Continue button text
* @param {string} [options.cancelText='Cancel'] - Cancel button text
* @param {string} [options.continueClass='btn btn-danger'] - Continue button CSS
* @param {string} [options.cancelClass='btn btn-warning'] - Cancel button CSS
* @param {boolean} [options.condition=true] - Only show if true
* @param {Function|null} [options.onShown] - Callback after modal opens
*
* @example
* modal.continueCancelModal({
* title: 'Unsaved Changes',
* body: 'You have unsaved changes. Continue?',
* onContinue: () => saveAndProceed()
* })
*/
function continueCancelModal({
title = '',
body = '',
onContinue = null,
continueText = 'Continue',
cancelText = 'Cancel',
continueClass = 'btn btn-danger w-50 py-2 rounded-3 shadow-sm fw-bold',
cancelClass = 'btn btn-light w-50 py-2 rounded-3 border fw-bold text-muted',
condition = true,
showCancel = true,
onShown = null
} = {}) {
if (condition !== true) return
const FooterComponent = {
render() {
const buttons = []
// Cancel button
if (showCancel) {
buttons.push(
h(
'button',
{
class: cancelClass,
onClick: () => {
close()
}
},
cancelText
)
)
}
// Continue button
buttons.push(
h(
'button',
{
class: continueClass,
onClick: () => {
close()
if (typeof onContinue === 'function') {
onContinue()
}
}
},
continueText
)
)
return h('div', { class: 'd-flex w-100 gap-3' }, buttons)
}
}
// Close any existing modal first
show.value = false
setTimeout(() => {
open({
title,
body,
footer: FooterComponent
})
if (typeof onShown === 'function') {
onShown()
}
}, 10)
}
/**
* Hides the currently open modal.
* Vue replacement for hideallmodals() and hidemodal()
*/
function hideModal() {
close()
}
/**
* Alias for hideModal (migration helper)
*/
function hideAllModals() {
close()
}
return {
show,
title,
body,
footer,
open,
close,
setBody,
setFooter,
quickDismiss,
quickDismissGoto,
yesNoModal,
continueCancelModal,
hideModal,
hideAllModals,
}
}

View File

@@ -0,0 +1,227 @@
// resources/js/composables/Core/useNavigate.js
import { getCurrentInstance, h } from 'vue'
import { encodeHash, encodePayload } from '../useUrlEncoder'
import { useUIStore } from '../../stores/ui'
import { useModal } from './useModal'
// Page module map — populated once from app.js for prefetching
let _pageModules = null;
export function setPageModules(modules) { _pageModules = modules; }
// Track which pages have already been prefetched to avoid duplicate calls
const _prefetchedPages = new Set();
export function useNavigate({ currentPage, currentProps, loading } = {}) {
const instance = getCurrentInstance()
const uiStore = useUIStore()
const modal = useModal()
// Try to get the global navigate helper first from window.$navigateHelper,
// then fall back to instance?.proxy?.$navigate
const globalNavigate = (window.$navigateHelper || instance?.proxy?.$navigate)
/**
* Prefetch a page component chunk by name.
* Call on hover/focus to start loading the JS bundle before navigation.
*/
const prefetch = (pageName) => {
if (!pageName || !_pageModules || _prefetchedPages.has(pageName)) return;
const loader = _pageModules[pageName];
if (loader) {
_prefetchedPages.add(pageName);
loader().catch(() => { /* chunk may not exist, ignore */ });
}
}
const navigate = async (arg1, arg2 = {}) => {
// Universal navigate handler that accepts:
// 1. navigate({ page: 'Home', props: {} })
// 2. navigate('Home', { target: '...' })
let page, props, delay, fromPopState;
if (typeof arg1 === 'string') {
page = arg1;
props = arg2.props || arg2 || {};
delay = arg2.delay || 0;
fromPopState = arg2.fromPopState || false;
} else {
({ page, props = {}, delay = 0, fromPopState = false } = arg1);
}
if (!page) {
console.error('[useNavigate] Navigation failed: No page specified.', { arg1, arg2 });
return;
}
// Global Page Disabled Check — fire-and-forget refresh if stale (don't block navigation)
const cacheThreshold = 30 * 1000; // 30 seconds
const isStale = !uiStore.lastSynced || (new Date() - new Date(uiStore.lastSynced) > cacheThreshold);
if (isStale) {
// Non-blocking: refresh in background, don't await
uiStore.refreshSettings().catch(e => {
console.error('[useNavigate] Failed to refresh settings during navigation:', e);
});
}
const pageLower = page.toLowerCase();
const isDisabled = uiStore.disabledPages && uiStore.disabledPages.some(p => p.toLowerCase() === pageLower);
if (isDisabled) {
console.warn(`[useNavigate] Page ${page} is globally disabled.`);
modal.open({
title: 'Access Restricted',
body: h('div', { class: 'text-center p-4' }, [
h('div', { class: 'icon-shape bg-soft-danger text-danger rounded-circle mx-auto mb-4 p-3 shadow-sm' }, [
h('i', { class: 'fas fa-lock fa-3x' })
]),
h('h4', { class: 'fw-black mb-3 text-dark' }, 'Page Offline'),
h('p', { class: 'text-muted mb-4 px-3' }, `The requested page (${page}) has been temporarily disabled by the system administrator.`),
h('button', {
class: 'btn btn-primary rounded-pill px-5 py-2 fw-bold shadow-primary-sm',
onClick: () => {
modal.close();
if (pageLower !== 'home') navigate({ page: 'Home' });
}
}, 'Back to Dashboard')
]),
footer: null
});
if (loading) loading.value = false;
return;
}
// If local refs are provided (root app usage), update them directly
if (currentPage || currentProps || loading) {
if (loading) loading.value = true
if (delay) await new Promise(r => setTimeout(r, delay))
if (currentPage) currentPage.value = page
if (currentProps) currentProps.value = props
// Update browser URL (only if NOT coming from a popstate event)
if (!fromPopState) {
let url;
if (page === 'Home') {
url = '/';
} else if (page === 'Auth.Login') {
url = '/login';
} else {
// Check for special props that should be encoded into URL
const hashkeyProp = props?.target || props?.target_user || props?.hashkey || props?.id;
const payloadProp = props?.payload;
let basePageUrl = `/${page
.replace(/\./g, '/')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.toLowerCase()}`;
if (hashkeyProp) {
// Encode the hashkey into URL as --h:ENCODED
const encodedHash = encodeHash(hashkeyProp);
url = `${basePageUrl}--${encodedHash}`;
} else if (payloadProp) {
// Encode the payload into URL as --e:ENCODED_PAYLOAD
const encodedPayload = encodePayload(payloadProp);
url = `${basePageUrl}--${encodedPayload}`;
} else {
url = basePageUrl;
}
}
window.history.pushState({ page, props }, '', url);
}
if (loading) loading.value = false
return
}
// In child components, fall back to the global $navigate helper
if (globalNavigate) {
globalNavigate({ page, props, delay, fromPopState })
} else {
console.warn('[useNavigate] No local refs and global navigate helper not found.')
}
}
const reloadPage = () => {
if (currentPage) {
navigate({ page: currentPage.value, props: currentProps?.value, delay: 0 })
} else if (globalNavigate) {
// In children, we can't easily get 'currentPage' unless we pass it or use a store
// but usually reloadPage is used in the context that knows the current state.
console.warn('[useNavigate] reloadPage called in child without refs.')
}
}
return { navigate, reloadPage, prefetch }
}
/**
* Helper to derive component name from a URL path in a generic way.
* Matches the logic in VueRouteMap.php.
*/
const getPageFromUrl = (path) => {
let cleanPath = path.trim().replace(/^\/+|\/+$/g, '');
if (!cleanPath) return 'Home';
// Handle explicit common mappings
if (cleanPath === 'login') return 'Auth.Login';
if (cleanPath === 'app') return 'Home';
// Strip hash/payload suffix (e.g., "view-store-market--h:ABC" -> "view-store-market")
const slug = cleanPath.split('--h:')[0].split('--e:')[0];
// Convert kebab-case/path to PascalCase/dots (e.g., "market/list-products" -> "Market.ListProducts")
return slug.split('/').map(part => {
return part.split('-').map(subPart => {
if (!subPart) return '';
return subPart.charAt(0).toUpperCase() + subPart.slice(1);
}).join('');
}).join('.');
}
// Global popstate listener to handle browser back button
if (typeof window !== 'undefined') {
window.addEventListener('popstate', (event) => {
if (event.state && event.state.page) {
if (window.$navigateHelper) {
window.$navigateHelper({
page: event.state.page,
props: event.state.props,
fromPopState: true,
delay: 0
})
} else {
// Fallback if app not yet initialized
window.location.reload()
}
} else {
// If no state (initial load entry point), attempt to restore from initial DOM payload
const el = document.getElementById('main-body');
if (el && el.dataset.page) {
try {
const initialPage = JSON.parse(el.dataset.page);
if (window.$navigateHelper) {
window.$navigateHelper({
page: initialPage.component,
props: initialPage.props,
fromPopState: true,
delay: 0
});
return;
}
} catch (e) {
console.error('[useNavigate] Failed to parse initial page from DOM:', e);
}
}
// Ultimate Fallback: Derive the page from the URL (without props, as we can't reliably decode them here)
const page = getPageFromUrl(window.location.pathname);
if (window.$navigateHelper) {
window.$navigateHelper({ page, fromPopState: true, delay: 0 })
}
}
})
}

View File

@@ -0,0 +1,17 @@
import { onMounted, onUnmounted } from 'vue'
import { useUIStore } from '../../stores/ui'
export function usePageTitle(title) {
const uiStore = useUIStore()
onMounted(() => {
if (title) {
uiStore.setPageTitle(title)
}
})
return {
setTitle: (newTitle) => uiStore.setPageTitle(newTitle),
resetTitle: () => uiStore.resetPageTitle()
}
}

View File

@@ -0,0 +1,115 @@
// resources/js/composables/Core/usePrefetch.js
import { useNavigate } from './useNavigate';
import { useAuth } from './useAuth';
import axios from 'axios';
import { usePrefetchStore } from '../../stores/prefetch';
export function usePrefetch() {
const { prefetch: prefetchJS } = useNavigate();
const { hasRole, UserTypes } = useAuth();
const prefetchStore = usePrefetchStore();
// Registry of pages and their corresponding data endpoints
// Each entry: { page: string, dataUrls: Array<{ url: string, method: string, payload: object }>, roles: string[] }
const registry = [
{
page: 'UserList',
dataUrls: [
{ url: '/admin/users/list', method: 'GET' }
],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR]
},
{
page: 'ManageStoresAdmin',
dataUrls: [{ url: '/Admin/Stores/List', method: 'POST' }],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR]
},
{
page: 'ListStores',
dataUrls: [{ url: '/ListStores/List/data', method: 'POST' }],
roles: 'all'
},
{
page: 'ManageProductsAdmin',
dataUrls: [{ url: '/Admin/Products/List', method: 'POST' }],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR]
},
{
page: 'ShipmentList',
dataUrls: [{ url: '/Shipments/List', method: 'POST' }],
roles: 'all'
},
{
page: 'CooperativeList',
dataUrls: [{ url: '/Cooperatives/List', method: 'POST' }],
roles: 'all'
},
{
page: 'VerificationDashboard',
dataUrls: [{ url: '/Farmers/List', method: 'POST' }],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.OPERATOR]
},
{
page: 'ListReports',
dataUrls: [{ url: '/admin/accounting/reports', method: 'POST' }],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR]
}
];
/**
* Prefetch a single data endpoint and store it in the persistent cache.
*/
const prefetchData = async ({ url, method = 'GET', payload = {} }) => {
try {
const cacheKey = `${method}:${url}:${JSON.stringify(payload)}`;
// Standardizing the request
let response;
if (method.toUpperCase() === 'POST') {
response = await axios.post(url, payload);
} else {
response = await axios.get(url, { params: payload });
}
if (response.data) {
prefetchStore.setCache(cacheKey, response.data);
console.log(`[Prefetch] Cached ${url}`);
}
} catch (error) {
console.warn(`[Prefetch] Failed to prefetch data for ${url}:`, error);
}
};
/**
* Trigger prefetching for ALL allowed modules.
* Staggered to avoid network congestion.
*/
const prefetchEverything = async () => {
const allowedModules = registry.filter(item => {
if (item.roles === 'all') return true;
return item.roles.some(role => hasRole(role));
});
console.log(`[Prefetch] Starting universal prefetch for ${allowedModules.length} modules.`);
// Process in a queue with small delay to avoid hammering
for (const module of allowedModules) {
// 1. Prefetch JS Chunk
prefetchJS(module.page);
// 2. Prefetch Data Endpoints
for (const dataInfo of module.dataUrls) {
// Background fetch, don't wait for completion before starting next module
prefetchData(dataInfo);
}
// Small break between modules
await new Promise(r => setTimeout(r, 200));
}
};
return {
prefetchEverything,
prefetchData
};
}

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