215 lines
6.4 KiB
JavaScript
215 lines
6.4 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');
|
|||
|
|
const monacoVs='/app/vendor/monaco/vs';
|
|||
|
|
s.src=monacoVs+'/loader.js';
|
|||
|
|
s.onload=function(){
|
|||
|
|
require.config({paths:{vs:monacoVs}});
|
|||
|
|
require(['vs/editor/editor.main'],function(){
|
|||
|
|
window.monaco=monaco;
|
|||
|
|
window._monacoReady=true;
|
|||
|
|
window._monacoLoading=false;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
s.onerror=function(){
|
|||
|
|
window._monacoLoading=false;
|
|||
|
|
console.warn('Monaco vendor preload failed:', monacoVs);
|
|||
|
|
};
|
|||
|
|
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; }
|