Files
aipackage/cezen-portal/auth.js
T

290 lines
14 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;
}
// Keep the streamlined prototype navigation consistent on every legacy
// page while features are progressively enabled.
function applyPrototypeNavigation() {
const topnav = document.querySelector('.topnav');
const nav = topnav && topnav.querySelector('nav');
if (!topnav || !nav) return;
const directUseCases = nav.querySelector('a[href="usecases.html"]');
if (directUseCases) directUseCases.remove();
const dropdowns = Array.from(nav.querySelectorAll('.nav-dropdown'));
const helpDropdown = dropdowns.find(el => {
const button = el.querySelector('.nav-drop-btn');
return button && button.textContent.trim().startsWith('Help');
});
if (helpDropdown) helpDropdown.remove();
nav.querySelectorAll('.nav-sep').forEach(el => el.remove());
const aiDropdown = Array.from(nav.querySelectorAll('.nav-dropdown')).find(el => {
const button = el.querySelector('.nav-drop-btn');
return button && button.textContent.trim().startsWith('AI Tools');
});
if (aiDropdown) {
const menu = aiDropdown.querySelector('.nav-drop-menu');
if (menu) {
menu.innerHTML = `
<span class="nav-drop-cat">BUILD /</span>
<a href="app-studio.html">App Studio</a>
<a href="applications.html">Applications</a>
<a href="dataset-creation.html">Dataset Creation</a>
<a href="training.html">Model Training</a>
<span class="nav-drop-cat">WORK /</span>
<a href="prompt-studio.html">Nexus Prompt Studio</a>
<a href="tool-openwebui.html">Open WebUI</a>
<a href="nexus-notebook.html">Nexus Notebook</a>
<span class="nav-drop-cat">AUTOMATE /</span>
<a href="agents.html">Agent Studio</a>
<span class="nav-drop-cat">QUALITY /</span>
<a href="evals.html">AI Evaluation</a>
<span class="nav-drop-cat">DEVELOPER /</span>
<a href="tool-jupyter.html">Jupyter Notebook</a>
`;
}
}
const adminDropdown = Array.from(nav.querySelectorAll('.nav-dropdown')).find(el => {
const button = el.querySelector('.nav-drop-btn');
return button && button.textContent.trim().startsWith('Admin');
});
let navRight = topnav.querySelector('.nav-right');
if (!navRight) {
navRight = document.createElement('div');
navRight.className = 'nav-right';
topnav.appendChild(navRight);
}
if (adminDropdown) navRight.insertBefore(adminDropdown, navRight.firstChild);
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
nav.querySelectorAll('a, .nav-drop-btn').forEach(el => el.classList.remove('active'));
const currentLink = nav.querySelector(`a[href="${currentPage}"]`);
if (currentLink) currentLink.classList.add('active');
if (aiDropdown && (currentPage === 'app-studio.html' || currentPage === 'applications.html' || currentPage === 'dataset-creation.html' || currentPage === 'training.html' || currentPage === 'prompt-studio.html' || currentPage === 'tool-openwebui.html' || currentPage === 'nexus-notebook.html' || currentPage === 'meeting.html' || currentPage === 'agents.html' || currentPage === 'workflows.html' || currentPage === 'schedules.html' || currentPage === 'evals.html' || currentPage === 'nexus-compare.html' || currentPage === 'rag-quality.html' || currentPage === 'feedback.html' || currentPage === 'tool-jupyter.html')) {
aiDropdown.querySelector('.nav-drop-btn')?.classList.add('active');
}
}
applyPrototypeNavigation();
// Consolidate overlapping tools under three product-level workspaces while
// preserving their existing routes and functionality.
(function applyProductModeNavigation() {
const page = window.location.pathname.split('/').pop() || 'index.html';
const products = [
{ name: 'Nexus Notebook', pages: ['nexus-notebook.html','meeting.html'], modes: [['Notebook','nexus-notebook.html'],['Meetings','meeting.html']] },
{ name: 'Agent Studio', pages: ['agents.html','workflows.html','schedules.html'], modes: [['Agents','agents.html'],['Workflows','workflows.html'],['Scheduled Jobs','schedules.html']] },
{ name: 'AI Evaluation', pages: ['evals.html','nexus-compare.html','rag-quality.html','feedback.html','model-compare.html'], modes: [['Eval Suites','evals.html'],['Model Compare','nexus-compare.html'],['RAG Quality','rag-quality.html'],['Feedback','feedback.html']] }
];
const product = products.find(item => item.pages.includes(page));
if (!product || document.querySelector('.product-mode-nav')) return;
const style = document.createElement('style');
style.textContent = '.product-mode-nav{height:44px;display:flex;align-items:center;gap:6px;padding:0 28px;border-bottom:1px solid var(--bdr,#e2e8f0);background:var(--navy2,#fff);position:relative;z-index:20}.product-mode-name{font-weight:700;font-size:13px;color:var(--ink,#172033);margin-right:12px}.product-mode-nav a{padding:7px 11px;border-radius:7px;color:var(--med,#64748b);font-size:12px;text-decoration:none}.product-mode-nav a:hover{background:rgba(124,58,237,.08);color:var(--purple,#7c3aed)}.product-mode-nav a.active{background:rgba(124,58,237,.12);color:var(--purple,#7c3aed);font-weight:700}@media(max-width:720px){.product-mode-nav{padding:0 12px;overflow-x:auto}.product-mode-name{display:none}.product-mode-nav a{white-space:nowrap}}';
document.head.appendChild(style);
const bar = document.createElement('div');
bar.className = 'product-mode-nav';
bar.setAttribute('aria-label', product.name + ' modes');
bar.innerHTML = '<span class="product-mode-name">' + product.name + '</span>' + product.modes.map(mode => '<a href="' + mode[1] + '"' + (mode[1] === page ? ' class="active" aria-current="page"' : '') + '>' + mode[0] + '</a>').join('');
document.querySelector('.topnav')?.insertAdjacentElement('afterend', bar);
})();
// ── 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: var(--brand-hover, #6D28D9);
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 var(--amber, #D97706);
animation: cezenFadeIn .3s ease;
}
@keyframes cezenFadeIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:none; } }
#cezen-timeout-banner.expired { border-color: var(--red, #DC2626); }
#cezen-timeout-banner strong { display:flex; align-items:center; gap:6px; 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:var(--green, #16A34A); 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><span class="icon-svg icon-xs" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></span> 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();
})();