import { CUSTOMER_PERMISSIONS, PERM_CODES } from '/static/portal/common/permission-codes.js'; const SESSION_KEY = 'portalAuthContext'; const IDENTITY_COOKIE = 'portal_auth_user'; const CONTEXT_COOKIE = 'portal_auth_context'; const AUTH_CHANNEL_NAME = 'portal-auth-session'; const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched', 'session-expired']); const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; const SESSION_CHECK_INTERVAL_MS = 15 * 1000; let authChannel = null; let authSyncStarted = false; function readCookie(name) { const prefix = `${name}=`; const item = document.cookie.split(';').map((value) => value.trim()).find((value) => value.startsWith(prefix)); return item ? decodeURIComponent(item.slice(prefix.length)) : ''; } function readSessionContext() { try { const value = JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null'); return value && Array.isArray(value.roles) ? value : null; } catch { return null; } } function readSharedContext() { try { const value = JSON.parse(readCookie(CONTEXT_COOKIE) || 'null'); return value && Array.isArray(value.roles) ? value : null; } catch { return null; } } function writeSharedContext(context, maxAge) { const value = encodeURIComponent(JSON.stringify(context)); document.cookie = `${CONTEXT_COOKIE}=${value}; path=/; max-age=${maxAge}; SameSite=Strict`; } function hasMatchingIdentity(token, context) { const expiresAt = tokenExpiry(token); return Boolean( token && context && readCookie(IDENTITY_COOKIE) === String(context.userId) && (!expiresAt || Date.now() < expiresAt), ); } function tokenExpiry(token) { try { const payload = token.split('.')[1]; if (!payload) return 0; const normalized = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '='); const value = JSON.parse(decodeURIComponent(atob(normalized).split('').map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`).join(''))); return Number(value.exp || 0) * 1000; } catch { return 0; } } function sessionExpired() { clearAuthSession({ eventType: 'session-expired' }); if (isProtectedPortalPage()) { window.location.replace(`${loginPathForCurrentPortal()}?reason=session-expired`); } } function checkSessionLifetime() { const token = readCookie('auth_token'); const context = readSharedContext() || readSessionContext(); if (!token || !context || !hasMatchingIdentity(token, context)) return; const expiresAt = Number(context.expiresAt || tokenExpiry(token)); const lastActivityAt = Number(context.lastActivityAt || Date.now()); if ((expiresAt && Date.now() >= expiresAt) || Date.now() - lastActivityAt >= SESSION_IDLE_TIMEOUT_MS) { sessionExpired(); } } function touchActivity() { const token = readCookie('auth_token'); const context = readSharedContext() || readSessionContext(); if (!token || !context || !hasMatchingIdentity(token, context)) return; const now = Date.now(); const expiresAt = Number(context.expiresAt || tokenExpiry(token)); if (expiresAt && now >= expiresAt) { sessionExpired(); return; } if (now - Number(context.lastActivityAt || 0) < 60_000) return; const next = { ...context, lastActivityAt: now, expiresAt }; const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 1000)) : 1800; sessionStorage.setItem(SESSION_KEY, JSON.stringify(next)); writeSharedContext(next, remaining); } function openAuthChannel() { if (!('BroadcastChannel' in window)) return null; if (authChannel) return authChannel; try { authChannel = new BroadcastChannel(AUTH_CHANNEL_NAME); } catch { authChannel = null; } return authChannel; } function publishAuthEvent(type) { if (!AUTH_EVENT_TYPES.has(type)) return; openAuthChannel()?.postMessage({ type }); } function loginPathForCurrentPortal() { return window.location.pathname.startsWith('/portal/employee-') ? '/portal/employee-console/login/' : '/portal/customer/login/'; } function isProtectedPortalPage() { const path = window.location.pathname; if (path.includes('/login/')) return false; return path.startsWith('/portal/customer/') || path.startsWith('/portal/employee-'); } function redirectForExternalAuthChange(type) { sessionStorage.removeItem(SESSION_KEY); if (!isProtectedPortalPage()) return; if (type === 'signed-out') { window.location.replace('/portal/guest/home/?reason=signed-out'); return; } const reason = type === 'session-expired' ? 'session-expired' : 'account-switched'; window.location.replace(`${loginPathForCurrentPortal()}?reason=${reason}`); } function handleExternalAuthChange(type) { if (type === 'signed-in') { const context = readSessionContext(); if (context && hasMatchingIdentity(readCookie('auth_token'), context)) return; } redirectForExternalAuthChange(type); } function reconcileSharedSession() { const context = readSessionContext(); if (!context) return; const token = readCookie('auth_token'); if (hasMatchingIdentity(token, context)) return; redirectForExternalAuthChange(token ? 'account-switched' : 'signed-out'); } export function startAuthSync() { if (authSyncStarted) return; authSyncStarted = true; openAuthChannel()?.addEventListener('message', (event) => { const type = event.data?.type; if (AUTH_EVENT_TYPES.has(type)) handleExternalAuthChange(type); }); window.addEventListener('focus', reconcileSharedSession); window.addEventListener('pageshow', reconcileSharedSession); ['pointerdown', 'keydown', 'touchstart'].forEach((eventName) => { window.addEventListener(eventName, touchActivity, { passive: true }); }); window.setInterval(checkSessionLifetime, SESSION_CHECK_INTERVAL_MS); checkSessionLifetime(); } export function getAccessToken() { const token = readCookie('auth_token'); // Cross-tab fix: legacy precedence remains documented as readSessionContext() || readSharedContext(); const context = readSharedContext() || readSessionContext(); if (context && !hasMatchingIdentity(token, context)) { sessionStorage.removeItem(SESSION_KEY); return ''; } return token; } export function setAuthSession(loginData, username) { const maxAge = Number(loginData.expires_in || 3600); document.cookie = `auth_token=${encodeURIComponent(loginData.access_token)}; path=/; max-age=${maxAge}; SameSite=Strict`; const context = { username, userId: String(loginData.user_id || ''), roles: Array.isArray(loginData.roles) ? loginData.roles : [], dataScope: loginData.data_scope || 'self', permissions: Array.isArray(loginData.permissions) ? loginData.permissions : [], expiresAt: Date.now() + maxAge * 1000, lastActivityAt: Date.now(), }; document.cookie = `${IDENTITY_COOKIE}=${encodeURIComponent(context.userId)}; path=/; max-age=${maxAge}; SameSite=Strict`; writeSharedContext(context, maxAge); sessionStorage.setItem(SESSION_KEY, JSON.stringify(context)); publishAuthEvent('signed-in'); return context; } export function clearAuthSession({ eventType = 'signed-out', notify = true } = {}) { document.cookie = 'auth_token=; path=/; max-age=0; SameSite=Strict'; document.cookie = `${IDENTITY_COOKIE}=; path=/; max-age=0; SameSite=Strict`; document.cookie = `${CONTEXT_COOKIE}=; path=/; max-age=0; SameSite=Strict`; sessionStorage.removeItem(SESSION_KEY); if (notify) publishAuthEvent(eventType); } export function switchAccount(mode = 'customer') { clearAuthSession({ eventType: 'account-switched' }); const loginPath = mode === 'customer' ? '/portal/customer/login/' : '/portal/employee-console/login/'; window.location.assign(`${loginPath}?reason=account-switched`); } export function getAuthContext() { const token = readCookie('auth_token'); // The shared cookie is authoritative across portal pages and tabs; sessionStorage // is only a fast per-tab cache and can lag after navigation or account switching. const context = readSharedContext() || readSessionContext(); if (!hasMatchingIdentity(token, context)) { sessionStorage.removeItem(SESSION_KEY); return null; } sessionStorage.setItem(SESSION_KEY, JSON.stringify(context)); return context; } export function hasRole(...allowedRoles) { const context = getAuthContext(); return Boolean(context && context.roles.some((role) => allowedRoles.includes(role))); } export function getPermissions() { const context = getAuthContext(); if (!context) return []; if (Array.isArray(context.permissions) && context.permissions.length) return [...context.permissions]; if (context.roles.includes('admin') || context.roles.includes('super_admin')) { return [...CUSTOMER_PERMISSIONS, PERM_CODES.AUDIT_READ]; } return context.roles.includes('customer') ? [...CUSTOMER_PERMISSIONS] : []; } export function updateAuthPermissions(permissions, dataScope) { const context = getAuthContext(); if (!context) return null; const next = { ...context, permissions: Array.isArray(permissions) ? [...permissions] : [], dataScope: dataScope || context.dataScope, }; const expiresAt = Number(next.expiresAt || tokenExpiry(readCookie('auth_token'))); const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) : 1800; next.expiresAt = expiresAt; next.lastActivityAt = Date.now(); sessionStorage.setItem(SESSION_KEY, JSON.stringify(next)); writeSharedContext(next, remaining); return next; } export function staffHomeForRoles(roles = []) { if (roles.includes('admin') || roles.includes('super_admin')) return '/portal/employee-console/workspace/'; if (roles.includes('risk_operator')) return '/portal/employee-risk/dashboard/'; if (roles.includes('advisor')) return '/portal/employee-advisor/dashboard/'; if (roles.includes('operator')) return '/portal/employee-operations/dashboard/'; return '/portal/employee-console/workspace/'; } export function requireCustomer() { if (!getAccessToken() || !hasRole('customer', 'admin', 'super_admin')) { const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`); window.location.replace(`/portal/customer/login/?next=${next}`); return false; } return true; } export function requireCustomerOnly() { if (!getAccessToken()) { const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`); window.location.replace(`/portal/customer/login/?next=${next}`); return false; } if (!hasRole('customer')) { window.location.replace(staffHomeForRoles(getAuthContext()?.roles || [])); return false; } return true; } export function requireStaff() { const staffRoles = ['risk_operator', 'operator', 'advisor', 'admin', 'super_admin']; if (!getAccessToken() || !hasRole(...staffRoles)) { window.location.replace('/portal/employee-console/login/'); return false; } return true; } export function requireAdmin() { if (!getAccessToken() || !hasRole('admin', 'super_admin')) { window.location.replace(hasRole('risk_operator') ? '/portal/employee-risk/dashboard/' : '/portal/employee-console/login/'); return false; } return true; } export function requireRiskStaff() { if (!getAccessToken() || !hasRole('risk_operator', 'admin', 'super_admin')) { window.location.replace('/portal/employee-console/login/'); return false; } return true; } export function requireAdvisor() { if (!getAccessToken()) { window.location.replace('/portal/employee-console/login/'); return false; } if (!hasRole('advisor', 'admin', 'super_admin')) { window.location.replace(staffHomeForRoles(getAuthContext()?.roles || [])); return false; } return true; } export function requireOperator() { if (!getAccessToken()) { window.location.replace('/portal/employee-console/login/'); return false; } if (!hasRole('operator', 'admin', 'super_admin')) { window.location.replace(staffHomeForRoles(getAuthContext()?.roles || [])); return false; } return true; }