201 lines
8.1 KiB
JavaScript
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();
|
|
})();
|