290 lines
14 KiB
JavaScript
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();
|
|
})();
|