b676e320de
- index.html: WebSocket告警文本esc()转义 - files.html: escAttr()函数 + 路径拼接转义 - layout.js: 移动端侧边栏overlay + 全局搜索 + 用户信息 - servers.html: 点击展开详情面板(系统信息/同步/SSH) - settings.html: TOTP QR码设置 + 修改密码 + API Key复制 - api.js: JWT refresh token自动刷新 + Toast通知 - terminal.html: WebSSH xterm.js集成 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
191 lines
5.7 KiB
JavaScript
191 lines
5.7 KiB
JavaScript
// Nexus — Shared API auth module (JWT Bearer + auto-refresh)
|
||
// 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: localStorage.getItem('refresh_token') || '',
|
||
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() {
|
||
const { refresh } = _getTokens();
|
||
if (!refresh) return false;
|
||
try {
|
||
const res = await fetch(API + '/auth/refresh', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refresh_token: refresh }),
|
||
});
|
||
if (!res.ok) return false;
|
||
const data = await res.json();
|
||
if (!data.success) return false;
|
||
localStorage.setItem('access_token', data.access_token);
|
||
localStorage.setItem('refresh_token', data.refresh_token || refresh);
|
||
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('refresh_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)
|
||
const { access, refresh } = _getTokens();
|
||
if (access) {
|
||
try {
|
||
const blob = new Blob(
|
||
[JSON.stringify({ refresh_token: refresh })],
|
||
{ type: 'application/json' }
|
||
);
|
||
navigator.sendBeacon(API + '/auth/logout', blob);
|
||
} 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
|
||
const token = localStorage.getItem('access_token') || '';
|
||
if (!token || !_checkSessionAge()) {
|
||
// Will redirect to login via _checkSessionAge → _logout
|
||
} else {
|
||
_recordActivity();
|
||
}
|
||
function ah() { return apiHeaders(); }
|
||
|
||
// ── 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; }
|