209 lines
6.3 KiB
JavaScript
209 lines
6.3 KiB
JavaScript
// Nexus — Shared API auth module (JWT Bearer + auto-refresh via HttpOnly cookie)
|
||
// Include before page-specific scripts: <script src="/app/api.js"></script>
|
||
|
||
const API = window.location.origin + '/api';
|
||
|
||
function _getTokens() {
|
||
return {
|
||
access: localStorage.getItem('access_token') || '',
|
||
// Refresh token is in HttpOnly cookie — not accessible from JS
|
||
expires: parseInt(localStorage.getItem('token_expires') || '0'),
|
||
};
|
||
}
|
||
|
||
function _isTokenExpiring() {
|
||
// Refresh if token expires within 2 minutes
|
||
return Date.now() > _getTokens().expires - 120000;
|
||
}
|
||
|
||
async function _refreshTokens() {
|
||
try {
|
||
// Refresh token is auto-sent as HttpOnly cookie by browser (same-origin)
|
||
const res = await fetch(API + '/auth/refresh', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
if (!res.ok) return false;
|
||
const data = await res.json();
|
||
if (!data.success) return false;
|
||
localStorage.setItem('access_token', data.access_token);
|
||
localStorage.setItem('token_expires', String(Date.now() + (data.expires_in || 1800) * 1000));
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function apiHeaders() {
|
||
const { access } = _getTokens();
|
||
return { 'Authorization': 'Bearer ' + access };
|
||
}
|
||
|
||
function apiHeadersJSON() {
|
||
return { ...apiHeaders(), 'Content-Type': 'application/json' };
|
||
}
|
||
|
||
async function apiFetch(url, options = {}) {
|
||
// Auto-refresh if token is about to expire
|
||
if (_isTokenExpiring()) {
|
||
const refreshed = await _refreshTokens();
|
||
if (!refreshed) {
|
||
_logout();
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Add auth headers
|
||
options.headers = { ...apiHeaders(), ...(options.headers || {}) };
|
||
|
||
const res = await fetch(url, options);
|
||
|
||
// Record session activity on successful API calls
|
||
if (res.status !== 401) {
|
||
_recordActivity();
|
||
}
|
||
|
||
// If 401, try refresh once
|
||
if (res.status === 401) {
|
||
const refreshed = await _refreshTokens();
|
||
if (refreshed) {
|
||
options.headers = { ...apiHeaders(), ...(options.headers || {}) };
|
||
return fetch(url, options);
|
||
}
|
||
_logout();
|
||
return null;
|
||
}
|
||
|
||
return res;
|
||
}
|
||
|
||
function _logout() {
|
||
localStorage.removeItem('access_token');
|
||
localStorage.removeItem('token_expires');
|
||
localStorage.removeItem('admin');
|
||
localStorage.removeItem('last_activity');
|
||
window.location.href = '/app/login.html';
|
||
}
|
||
|
||
function doLogout() {
|
||
// Notify backend (best-effort, don't block)
|
||
// Refresh token is in HttpOnly cookie — auto-sent by browser
|
||
const { access } = _getTokens();
|
||
if (access) {
|
||
try {
|
||
fetch(API + '/auth/logout', {
|
||
method: 'POST',
|
||
keepalive: true,
|
||
credentials: 'same-origin',
|
||
});
|
||
} catch {}
|
||
}
|
||
_logout();
|
||
}
|
||
|
||
// ── Session Inactivity Timeout ──
|
||
// Auto-logout after 8 hours of no API activity
|
||
const SESSION_MAX_AGE_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||
|
||
function _checkSessionAge() {
|
||
const lastActivity = parseInt(localStorage.getItem('last_activity') || '0');
|
||
if (lastActivity && Date.now() - lastActivity > SESSION_MAX_AGE_MS) {
|
||
_logout();
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function _recordActivity() {
|
||
localStorage.setItem('last_activity', String(Date.now()));
|
||
}
|
||
|
||
// Backward-compatible alias — try refresh cookie if no access token
|
||
const token = localStorage.getItem('access_token') || '';
|
||
if (!token) {
|
||
// No access token — try refresh via HttpOnly cookie (auto-sent by browser)
|
||
_refreshTokens().then(ok => {
|
||
if (!ok) _logout();
|
||
else _recordActivity();
|
||
});
|
||
} else if (!_checkSessionAge()) {
|
||
// Session expired — will redirect to login via _checkSessionAge → _logout
|
||
} else {
|
||
_recordActivity();
|
||
}
|
||
|
||
// ── Monaco Editor 预加载(后台,不阻塞页面) ──
|
||
(function preloadMonaco(){
|
||
if(window._monacoLoading||window._monacoReady)return;
|
||
window._monacoLoading=true;
|
||
const s=document.createElement('script');
|
||
s.src='https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
|
||
s.onload=function(){
|
||
require.config({paths:{vs:'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'}});
|
||
require(['vs/editor/editor.main'],function(){
|
||
window.monaco=monaco;
|
||
window._monacoReady=true;
|
||
});
|
||
};
|
||
document.head.appendChild(s);
|
||
})();
|
||
|
||
// ── Global Toast Notification System ──
|
||
// Call toast('success', '操作成功') or toast('error', '操作失败') from any page.
|
||
|
||
let _toastContainer = null;
|
||
let _toastId = 0;
|
||
|
||
function _ensureToastContainer() {
|
||
if (_toastContainer) return;
|
||
_toastContainer = document.createElement('div');
|
||
_toastContainer.id = 'toastContainer';
|
||
_toastContainer.className = 'fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none';
|
||
_toastContainer.style.maxWidth = '380px';
|
||
document.body.appendChild(_toastContainer);
|
||
}
|
||
|
||
function toast(type, message, duration) {
|
||
_ensureToastContainer();
|
||
duration = duration || 3000;
|
||
const id = ++_toastId;
|
||
|
||
const colors = {
|
||
success: 'bg-green-900/90 border-green-700/50 text-green-200',
|
||
error: 'bg-red-900/90 border-red-700/50 text-red-200',
|
||
warning: 'bg-yellow-900/90 border-yellow-700/50 text-yellow-200',
|
||
info: 'bg-blue-900/90 border-blue-700/50 text-blue-200',
|
||
};
|
||
const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' };
|
||
|
||
const el = document.createElement('div');
|
||
el.id = 'toast-' + id;
|
||
el.className = `pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-xl border shadow-lg text-sm backdrop-blur-sm transition-all duration-300 opacity-0 translate-x-4 ${colors[type] || colors.info}`;
|
||
el.innerHTML = `<span class="text-base">${icons[type] || 'ℹ'}</span><span class="flex-1">${_toastEsc(message)}</span><button onclick="dismissToast(${id})" class="text-current opacity-50 hover:opacity-100 text-xs ml-2">✕</button>`;
|
||
|
||
_toastContainer.appendChild(el);
|
||
|
||
// Animate in
|
||
requestAnimationFrame(() => {
|
||
el.classList.remove('opacity-0', 'translate-x-4');
|
||
el.classList.add('opacity-100', 'translate-x-0');
|
||
});
|
||
|
||
// Auto-dismiss
|
||
if (duration > 0) {
|
||
setTimeout(() => dismissToast(id), duration);
|
||
}
|
||
|
||
return id;
|
||
}
|
||
|
||
function dismissToast(id) {
|
||
const el = document.getElementById('toast-' + id);
|
||
if (!el) return;
|
||
el.classList.add('opacity-0', 'translate-x-4');
|
||
el.classList.remove('opacity-100', 'translate-x-0');
|
||
setTimeout(() => el.remove(), 300);
|
||
}
|
||
|
||
function _toastEsc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|