aipackage/cezen-portal/auth.js
2026-06-30 10:51:41 +05:30

201 lines
8.1 KiB
JavaScript

/**
* Nexus One AI — Shared Auth Guard
* Include in every portal page. Checks session, injects user info into nav.
*
* Usage: <script src="auth.js"></script> (place just before </body>)
*
* Pages that do NOT require auth: login.html
* Admin-only pages: set data-role="admin" on the <body> tag.
*/
// ── Session timeout constants ────────────────────────────────────────────────
const SESSION_HOURS = 8; // must match JWT_EXPIRE_HRS in main.py
const WARN_BEFORE_MIN = 10; // show warning this many minutes before expiry
const EXTEND_INTERVAL = 60000; // poll session state every 60 seconds
(async function () {
// Don't guard the login page itself
if (window.location.pathname.endsWith('login.html')) return;
let user = null;
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
if (!res.ok) throw new Error('unauthenticated');
user = await res.json();
} catch {
window.location.href = '/login.html?next=' + encodeURIComponent(window.location.pathname);
return;
}
// Force password change
if (user.must_change_password && !window.location.pathname.endsWith('change-password.html')) {
window.location.href = '/change-password.html';
return;
}
// Admin-only pages
if (document.body.dataset.role === 'admin' && user.role !== 'admin') {
window.location.href = '/index.html';
return;
}
// ── Inject user chip + logout into nav ─────────────────────────────────────
const topnav = document.querySelector('.topnav');
if (topnav) {
document.querySelectorAll('[data-admin-only]').forEach(el => {
if (user.role !== 'admin') el.style.display = 'none';
});
const chip = document.createElement('div');
chip.className = 'nav-user-chip';
chip.innerHTML = `
<span class="nav-user-avatar">${user.username.charAt(0).toUpperCase()}</span>
<span class="nav-user-info">
<span class="nav-user-name">${user.username}</span>
${user.role === 'admin' ? '<span class="nav-user-role">Administrator</span>' : '<span class="nav-user-role">User</span>'}
</span>
<button class="nav-logout-btn" id="cezen-logout">Sign out</button>
`;
topnav.appendChild(chip);
document.getElementById('cezen-logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login.html';
});
}
// Expose user globally for page scripts
window.cezenUser = user;
document.dispatchEvent(new CustomEvent('cezenAuthReady', { detail: user }));
// ── Online users pill in navbar (admin only) ────────────────────────────────
if (user.role === 'admin' && topnav) {
const pill = document.createElement('a');
pill.id = 'cezen-online-pill';
pill.href = '/dashboard.html#sessions';
pill.title = 'View active sessions';
pill.style.cssText = [
'display:inline-flex;align-items:center;gap:5px;',
'background:rgba(21,128,61,.08);border:1px solid rgba(21,128,61,.2);',
'color:#15803D;border-radius:20px;padding:3px 10px 3px 8px;',
'font-size:11.5px;font-weight:600;text-decoration:none;',
'margin-right:2px;cursor:pointer;transition:.15s;flex-shrink:0;',
].join('');
pill.innerHTML = '<span style="width:6px;height:6px;border-radius:50%;background:#22c55e;display:inline-block;flex-shrink:0"></span><span id="cezen-online-count">…</span> online';
// Insert before user chip (last child)
topnav.insertBefore(pill, topnav.lastElementChild);
async function refreshOnlineCount() {
try {
const d = await fetch('/api/users/sessions', { credentials: 'include' }).then(r => r.json());
const n = (d.sessions || []).length;
document.getElementById('cezen-online-count').textContent = n;
pill.title = (d.sessions || []).map(s => s.username).join(', ') || 'No active sessions';
} catch { /* silently ignore */ }
}
refreshOnlineCount();
setInterval(refreshOnlineCount, 30000);
}
// ── Session timeout banner ──────────────────────────────────────────────────
// Record login time in sessionStorage so we can track elapsed time
const loginKey = 'cezen_login_ts';
if (!sessionStorage.getItem(loginKey)) {
sessionStorage.setItem(loginKey, Date.now().toString());
}
// Inject banner CSS + element
const style = document.createElement('style');
style.textContent = `
#cezen-timeout-banner {
display: none;
position: fixed;
bottom: 20px;
right: 20px;
background: #1E3A5F;
color: white;
border-radius: 12px;
padding: 14px 18px;
font-size: 13px;
font-family: inherit;
z-index: 9999;
box-shadow: 0 8px 32px rgba(0,0,0,.35);
max-width: 320px;
border-left: 4px solid #F59E0B;
animation: cezenFadeIn .3s ease;
}
@keyframes cezenFadeIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:none; } }
#cezen-timeout-banner.expired { border-color: #EF4444; }
#cezen-timeout-banner strong { display:block; margin-bottom:4px; font-size:14px; }
#cezen-timeout-banner .tb-actions { display:flex; gap:8px; margin-top:10px; }
#cezen-timeout-banner .tb-btn {
flex:1; padding:6px 0; border:none; border-radius:7px; font-size:12px;
font-weight:700; cursor:pointer; font-family:inherit;
}
#cezen-timeout-banner .tb-extend { background:#0D9488; color:white; }
#cezen-timeout-banner .tb-logout { background:rgba(255,255,255,.15); color:white; }
`;
document.head.appendChild(style);
const banner = document.createElement('div');
banner.id = 'cezen-timeout-banner';
banner.innerHTML = `
<strong>⏱ Session expiring soon</strong>
<span id="cezen-timeout-msg">Your session expires in <b id="cezen-countdown"></b></span>
<div class="tb-actions">
<button class="tb-btn tb-extend" id="cezen-extend">Stay signed in</button>
<button class="tb-btn tb-logout" id="cezen-tb-logout">Sign out</button>
</div>
`;
document.body.appendChild(banner);
document.getElementById('cezen-extend').addEventListener('click', async () => {
// Touching /api/auth/me re-validates the cookie; for a true refresh we'd
// need a /api/auth/refresh endpoint — for now just reset the local timer.
sessionStorage.setItem(loginKey, Date.now().toString());
banner.style.display = 'none';
});
document.getElementById('cezen-tb-logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login.html';
});
function fmt(ms) {
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function checkTimeout() {
const loginTs = parseInt(sessionStorage.getItem(loginKey) || Date.now(), 10);
const expireMs = SESSION_HOURS * 3600 * 1000;
const elapsed = Date.now() - loginTs;
const remaining = expireMs - elapsed;
const warnMs = WARN_BEFORE_MIN * 60 * 1000;
if (remaining <= 0) {
// Expired — redirect to login
banner.classList.add('expired');
banner.style.display = 'block';
document.getElementById('cezen-timeout-msg').innerHTML = 'Your session has expired.';
document.getElementById('cezen-extend').style.display = 'none';
setTimeout(() => { window.location.href = '/login.html'; }, 3000);
return;
}
if (remaining <= warnMs) {
banner.style.display = 'block';
const cd = document.getElementById('cezen-countdown');
if (cd) cd.textContent = fmt(remaining);
} else {
banner.style.display = 'none';
}
}
// Run every second for accurate countdown; heavier session validation every minute
setInterval(checkTimeout, 1000);
checkTimeout();
})();