initial: bootstrap from BukidBountyApp base
This commit is contained in:
147
resources/js/composables/Core/useAuth.js
Normal file
147
resources/js/composables/Core/useAuth.js
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
309
resources/js/composables/Core/useModal.js
Normal file
309
resources/js/composables/Core/useModal.js
Normal 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,
|
||||
}
|
||||
}
|
||||
227
resources/js/composables/Core/useNavigate.js
Normal file
227
resources/js/composables/Core/useNavigate.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
17
resources/js/composables/Core/usePageTitle.js
Normal file
17
resources/js/composables/Core/usePageTitle.js
Normal 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()
|
||||
}
|
||||
}
|
||||
115
resources/js/composables/Core/usePrefetch.js
Normal file
115
resources/js/composables/Core/usePrefetch.js
Normal 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
|
||||
};
|
||||
}
|
||||
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