Files
group_fqcd_jr/app/static/portal/common/auth.js
T

336 lines
12 KiB
JavaScript

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