Files
Nexus/web/app/api.js
T
Your Name b676e320de
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
前端增强: XSS防护 + 共享布局 + 移动适配 + 设置页
- 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>
2026-05-22 22:29:06 +08:00

191 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; }