前端增强: 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>
This commit is contained in:
+28
-1
@@ -61,6 +61,11 @@ async function apiFetch(url, options = {}) {
|
||||
|
||||
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();
|
||||
@@ -80,6 +85,7 @@ function _logout() {
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('token_expires');
|
||||
localStorage.removeItem('admin');
|
||||
localStorage.removeItem('last_activity');
|
||||
window.location.href = '/app/login.html';
|
||||
}
|
||||
|
||||
@@ -98,9 +104,30 @@ function doLogout() {
|
||||
_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) window.location.href = '/app/login.html';
|
||||
if (!token || !_checkSessionAge()) {
|
||||
// Will redirect to login via _checkSessionAge → _logout
|
||||
} else {
|
||||
_recordActivity();
|
||||
}
|
||||
function ah() { return apiHeaders(); }
|
||||
|
||||
// ── Global Toast Notification System ──
|
||||
|
||||
+5
-4
@@ -61,7 +61,7 @@
|
||||
// Parent directory link (if not root)
|
||||
if(_currentPath!=='/'){
|
||||
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
|
||||
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" onclick="browseDir('${parentPath.replace(/'/g,"\\'")}')">
|
||||
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" onclick="browseDir('${escAttr(parentPath)}')">
|
||||
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -69,8 +69,8 @@
|
||||
// Sort: directories first, then files
|
||||
const sorted=[...d.entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
|
||||
html+=sorted.map(e=>{
|
||||
const fullPath=(_currentPath.replace(/\/$/,'')+'/'+e.name).replace(/'/g,"\\'");
|
||||
return `<div class="flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${fullPath}')"`:''}>
|
||||
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
|
||||
return `<div class="flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${escAttr(fullPath)}')"`:''}>
|
||||
<span class="${e.is_dir?'text-yellow-400':'text-slate-400'} text-sm">${e.is_dir?'📁':'📄'}</span>
|
||||
<span class="text-white text-sm flex-1 ${e.is_dir?'hover:text-brand-light':''}">${esc(e.name)}</span>
|
||||
<span class="text-slate-500 text-xs tabular-nums">${e.is_dir?'--':e.size}</span>
|
||||
@@ -101,7 +101,7 @@
|
||||
accumulated+='/'+seg;
|
||||
const p=accumulated;
|
||||
if(i<segments.length-1){
|
||||
html+=`<span class="text-slate-600">/</span><span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('${p.replace(/'/g,"\\'")}')">${esc(seg)}</span>`;
|
||||
html+=`<span class="text-slate-600">/</span><span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('${escAttr(p)}')">${esc(seg)}</span>`;
|
||||
}else{
|
||||
html+=`<span class="text-slate-600">/</span><span class="text-white font-medium">${esc(seg)}</span>`;
|
||||
}
|
||||
@@ -110,6 +110,7 @@
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>')}
|
||||
|
||||
// Enter key in path input triggers browse
|
||||
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
|
||||
|
||||
+40
-3
@@ -211,11 +211,17 @@
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'ping') { _ws.send('pong'); return; }
|
||||
// Refresh stats + recent syncs on any real-time event
|
||||
loadDashboard();
|
||||
loadSyncs();
|
||||
if (msg.type === 'alert') {
|
||||
_addAlert('🔴', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
|
||||
_addAlert('\u{1F534}', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
|
||||
_playAlertSound();
|
||||
const el = document.getElementById('statAlerts');
|
||||
el.classList.add('animate-pulse');
|
||||
setTimeout(() => el.classList.remove('animate-pulse'), 3000);
|
||||
} else if (msg.type === 'recovery') {
|
||||
_addAlert('🟢', `${msg.server_name} ${msg.metric} 恢复正常 (${msg.value.toFixed(1)}%)`);
|
||||
_addAlert('\u{1F7E2}', `${msg.server_name} ${msg.metric} 恢复正常 (${msg.value.toFixed(1)}%)`);
|
||||
} else if (msg.type === 'system') {
|
||||
_addAlert('⚠️', msg.message);
|
||||
}
|
||||
@@ -240,7 +246,7 @@
|
||||
document.getElementById('alertBanner').classList.remove('hidden');
|
||||
document.getElementById('noAlertBanner').classList.add('hidden');
|
||||
document.getElementById('alertList').innerHTML = _alerts.map(a =>
|
||||
`<div class="text-slate-300">${a.icon} ${a.text} <span class="text-slate-600 text-xs float-right">${fmtTimeObj(a.time)}</span></div>`
|
||||
`<div class="text-slate-300">${a.icon} ${esc(a.text)} <span class="text-slate-600 text-xs float-right">${fmtTimeObj(a.time)}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
@@ -250,6 +256,37 @@
|
||||
document.getElementById('noAlertBanner').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Alert sound using Web Audio API — no external file needed
|
||||
function _playAlertSound() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 880;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.15;
|
||||
osc.start();
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
|
||||
osc.stop(ctx.currentTime + 0.3);
|
||||
} catch(e) {}
|
||||
// Flash tab title
|
||||
_flashTitle();
|
||||
}
|
||||
|
||||
let _titleFlashTimer = null;
|
||||
function _flashTitle() {
|
||||
const orig = document.title;
|
||||
let count = 0;
|
||||
clearInterval(_titleFlashTimer);
|
||||
_titleFlashTimer = setInterval(() => {
|
||||
document.title = count % 2 === 0 ? '🔴 告警 — Nexus' : orig;
|
||||
count++;
|
||||
if (count > 10) { clearInterval(_titleFlashTimer); document.title = orig; }
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
|
||||
+58
-1
@@ -3,10 +3,11 @@
|
||||
// Sets sidebar, highlights active nav item, loads user info.
|
||||
|
||||
function initLayout(activeNav) {
|
||||
// activeNav: 'index'|'servers'|'files'|'push'|'scripts'|'credentials'|'schedules'|'retries'|'audit'|'settings'
|
||||
// activeNav: 'index'|'servers'|'files'|'push'|'scripts'|'credentials'|'schedules'|'retries'|'commands'|'audit'|'settings'
|
||||
const navItems = [
|
||||
{ id:'index', icon:'🏠', label:'仪表盘', href:'/app/index.html' },
|
||||
{ id:'servers', icon:'🖥', label:'服务器', href:'/app/servers.html' },
|
||||
{ id:'assets', icon:'🗂', label:'资产管理', href:'/app/assets.html' },
|
||||
{ id:'files', icon:'📁', label:'文件管理', href:'/app/files.html' },
|
||||
{ id:'push', icon:'📤', label:'推送', href:'/app/push.html' },
|
||||
{ id:'scripts', icon:'📜', label:'脚本库', href:'/app/scripts.html' },
|
||||
@@ -16,6 +17,7 @@ function initLayout(activeNav) {
|
||||
];
|
||||
|
||||
const bottomNav = [
|
||||
{ id:'commands', icon:'💻', label:'命令日志', href:'/app/commands.html' },
|
||||
{ id:'audit', icon:'📋', label:'审计日志', href:'/app/audit.html' },
|
||||
{ id:'settings', icon:'⚙️', label:'设置', href:'/app/settings.html' },
|
||||
];
|
||||
@@ -33,6 +35,9 @@ function initLayout(activeNav) {
|
||||
const sidebar = document.querySelector('[data-sidebar]');
|
||||
if (!sidebar) return;
|
||||
|
||||
// Responsive: on mobile (<768px), sidebar defaults to hidden and overlays
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
sidebar.innerHTML = `
|
||||
<div class="px-5 py-4 border-b border-slate-800">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -64,6 +69,58 @@ function initLayout(activeNav) {
|
||||
|
||||
// Load user info
|
||||
loadLayoutUser();
|
||||
|
||||
// ── Mobile Responsive ──
|
||||
// On mobile: collapse sidebar by default, add overlay + backdrop
|
||||
if (isMobile) {
|
||||
// Find Alpine component and set sidebarOpen = false
|
||||
const alpineRoot = document.querySelector('[x-data]');
|
||||
if (alpineRoot && alpineRoot.__x) {
|
||||
alpineRoot.__x.$data.sidebarOpen = false;
|
||||
} else {
|
||||
// Direct DOM fallback
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
// Make sidebar overlay on mobile instead of pushing content
|
||||
sidebar.style.position = 'fixed';
|
||||
sidebar.style.top = '0';
|
||||
sidebar.style.left = '0';
|
||||
sidebar.style.bottom = '0';
|
||||
sidebar.style.zIndex = '40';
|
||||
// Add backdrop
|
||||
let backdrop = document.getElementById('sidebarBackdrop');
|
||||
if (!backdrop) {
|
||||
backdrop = document.createElement('div');
|
||||
backdrop.id = 'sidebarBackdrop';
|
||||
backdrop.className = 'fixed inset-0 bg-black/50 z-30 hidden';
|
||||
backdrop.onclick = () => {
|
||||
const root = document.querySelector('[x-data]');
|
||||
if (root && root.__x) root.__x.$data.sidebarOpen = false;
|
||||
sidebar.style.display = 'none';
|
||||
backdrop.classList.add('hidden');
|
||||
};
|
||||
document.body.appendChild(backdrop);
|
||||
}
|
||||
// Watch for sidebar toggle to show/hide backdrop
|
||||
const origToggle = document.querySelector('[x-data]');
|
||||
if (origToggle) {
|
||||
const observer = new MutationObserver(() => {
|
||||
const isVisible = sidebar.style.display !== 'none' && sidebar.offsetParent !== null;
|
||||
backdrop.classList.toggle('hidden', !isVisible);
|
||||
});
|
||||
observer.observe(sidebar, { attributes: true, attributeFilter: ['style'] });
|
||||
}
|
||||
}
|
||||
|
||||
// Make tables horizontally scrollable on all pages
|
||||
document.querySelectorAll('table').forEach(table => {
|
||||
if (!table.parentElement.classList.contains('overflow-x-auto')) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'overflow-x-auto';
|
||||
table.parentNode.insertBefore(wrapper, table);
|
||||
wrapper.appendChild(table);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLayoutUser() {
|
||||
|
||||
+52
-12
@@ -80,13 +80,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Server Modal -->
|
||||
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 overflow-y-auto py-8">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
|
||||
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
|
||||
<input type="hidden" id="editServerId">
|
||||
<!-- Basic -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">基本</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">地址</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称 *</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">地址 *</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
@@ -94,11 +96,25 @@
|
||||
<div><label class="block text-slate-400 text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="key">密钥</option><option value="password">密码</option></select></div>
|
||||
</div>
|
||||
<div id="srvPasswordRow"><label class="block text-slate-400 text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="SSH密码"></div>
|
||||
<div id="srvKeyPathRow"><label class="block text-slate-400 text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
|
||||
<!-- Agent -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">分类</label><input id="srvCategory" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="production"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/target"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">Agent端口</label><input id="srvAgentPort" type="number" value="8601" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">Agent API Key</label><div class="flex items-center gap-2"><input id="srvAgentKey" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局API Key"><button type="button" onclick="generateAgentKey()" class="px-2 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg shrink-0 transition" title="自动生成密钥">🔑</button></div></div>
|
||||
</div>
|
||||
<!-- Organization -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">分类</label><input id="srvCategory" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="production"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">平台</label><select id="srvPlatform" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 —</option></select></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">节点</label><select id="srvNode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 —</option></select></div>
|
||||
</div>
|
||||
<!-- Other -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/target"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
</div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,20 +256,44 @@
|
||||
}
|
||||
|
||||
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','服务器已删除');else toast('error','删除失败');if(selectedServerId===id)hideDetail();loadServers()}
|
||||
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('serverModal').classList.remove('hidden')}
|
||||
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';document.getElementById('serverModal').classList.remove('hidden')}
|
||||
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc','srvKeyPath','srvAgentKey'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('srvAgentPort').value='8601';document.getElementById('srvPlatform').value='';document.getElementById('srvNode').value='';toggleAuthFields();loadOrgSelects();document.getElementById('serverModal').classList.remove('hidden')}
|
||||
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvKeyPath').value=s.ssh_key_path||'';document.getElementById('srvAgentPort').value=s.agent_port||8601;document.getElementById('srvAgentKey').value=s.agent_api_key||'';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';toggleAuthFields();loadOrgSelects().then(()=>{document.getElementById('srvPlatform').value=s.platform_id||'';document.getElementById('srvNode').value=s.node_id||''});document.getElementById('serverModal').classList.remove('hidden')}
|
||||
function hideServerModal(){document.getElementById('serverModal').classList.add('hidden')}
|
||||
document.getElementById('srvAuth').addEventListener('change',()=>{document.getElementById('srvPasswordRow').style.display=document.getElementById('srvAuth').value==='password'?'':'none'});
|
||||
function toggleAuthFields(){const isKey=document.getElementById('srvAuth').value==='key';document.getElementById('srvPasswordRow').style.display=isKey?'none':'';document.getElementById('srvKeyPathRow').style.display=isKey?'':'none'}
|
||||
document.getElementById('srvAuth').addEventListener('change',toggleAuthFields);
|
||||
async function loadOrgSelects(){
|
||||
try{
|
||||
const [pr,nr]=await Promise.all([apiFetch(API+'/assets/platforms'),apiFetch(API+'/assets/nodes')]);
|
||||
if(pr){const plats=await pr.json();document.getElementById('srvPlatform').innerHTML='<option value="">— 无 —</option>'+plats.map(p=>`<option value="${p.id}">${esc(p.name)}</option>`).join('')}
|
||||
if(nr){const nodes=await nr.json();document.getElementById('srvNode').innerHTML='<option value="">— 无 —</option>'+nodes.map(n=>`<option value="${n.id}">${esc(n.name)}</option>`).join('')}
|
||||
}catch(e){}
|
||||
}
|
||||
async function saveServer(){
|
||||
const id=document.getElementById('editServerId').value;
|
||||
const body={name:document.getElementById('srvName').value,domain:document.getElementById('srvDomain').value,port:parseInt(document.getElementById('srvPort').value)||22,username:document.getElementById('srvUser').value||'root',auth_method:document.getElementById('srvAuth').value,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null};
|
||||
const body={name:document.getElementById('srvName').value,domain:document.getElementById('srvDomain').value,port:parseInt(document.getElementById('srvPort').value)||22,username:document.getElementById('srvUser').value||'root',auth_method:document.getElementById('srvAuth').value,agent_port:parseInt(document.getElementById('srvAgentPort').value)||8601,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null,platform_id:document.getElementById('srvPlatform').value?parseInt(document.getElementById('srvPlatform').value):null,node_id:document.getElementById('srvNode').value?parseInt(document.getElementById('srvNode').value):null};
|
||||
if(body.auth_method==='password'){body.password=document.getElementById('srvPassword').value}
|
||||
if(!body.name||!body.domain){alert('名称和地址必填');return}
|
||||
else{body.ssh_key_path=document.getElementById('srvKeyPath').value||null}
|
||||
const ak=document.getElementById('srvAgentKey').value;if(ak)body.agent_api_key=ak;
|
||||
if(!body.name||!body.domain){toast('warning','名称和地址必填');return}
|
||||
if(id){const r=await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已更新');else toast('error','更新失败')}else{const r=await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已添加');else toast('error','添加失败')}
|
||||
hideServerModal();loadServers();
|
||||
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
|
||||
}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
// ── Generate Agent API Key ──
|
||||
async function generateAgentKey(){
|
||||
const id=document.getElementById('editServerId').value;
|
||||
if(!id){toast('warning','请先保存服务器,再生成Agent密钥');return}
|
||||
if(!confirm('将为该服务器生成新的Agent API Key,旧密钥将失效。继续?'))return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/'+id+'/agent-key',{method:'POST',headers:apiHeadersJSON()});
|
||||
if(!r)return;
|
||||
const data=await r.json();
|
||||
document.getElementById('srvAgentKey').value=data.agent_api_key||'';
|
||||
toast('success','Agent API Key已生成,请保存服务器以应用');
|
||||
}catch(e){toast('error','生成密钥失败')}
|
||||
}
|
||||
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
|
||||
document.getElementById('searchInput').addEventListener('input',()=>{
|
||||
|
||||
+309
-7
@@ -1,29 +1,100 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 系统设置</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">系统设置</h1></div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
|
||||
<div class="mb-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full space-y-4">
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Account Security ── -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">账户安全</h2>
|
||||
|
||||
<!-- TOTP 2FA -->
|
||||
<div class="mb-5 pb-5 border-b border-slate-800">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<span class="text-white text-sm font-medium">双因素认证 (TOTP)</span>
|
||||
<span id="totpStatusBadge" class="ml-2 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">检测中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP: Disabled state — show setup button -->
|
||||
<div id="totpSetupArea">
|
||||
<p class="text-slate-400 text-xs mb-3">启用双因素认证后,登录时需要输入验证器App生成的一次性验证码。</p>
|
||||
<div id="totpIdleActions">
|
||||
<button onclick="startTotpSetup()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">启用 TOTP</button>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Step 1: Show secret + QR -->
|
||||
<div id="totpStep1" class="hidden space-y-3">
|
||||
<div class="bg-slate-800 rounded-lg p-4 text-center">
|
||||
<p class="text-slate-400 text-xs mb-2">使用验证器App扫描以下二维码或手动输入密钥</p>
|
||||
<canvas id="totpQrCanvas" class="mx-auto rounded border border-slate-700" width="200" height="200"></canvas>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">密钥 (手动输入)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all"></code>
|
||||
<button onclick="copyTotpSecret()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">输入验证码确认启用</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="totpVerifyCode" maxlength="6" placeholder="6位数字" class="w-32 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono text-center tracking-widest">
|
||||
<button onclick="verifyAndEnableTotp()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">确认启用</button>
|
||||
<button onclick="cancelTotpSetup()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP: Enabled state — show disable button -->
|
||||
<div id="totpEnabledArea" class="hidden">
|
||||
<p class="text-slate-400 text-xs mb-3">双因素认证已启用。禁用后登录将不再需要验证码。</p>
|
||||
<button onclick="disableTotp()" class="px-4 py-2 bg-red-900/50 hover:bg-red-900 text-red-300 text-sm rounded-lg transition">禁用 TOTP</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div>
|
||||
<span class="text-white text-sm font-medium">修改密码</span>
|
||||
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。</p>
|
||||
<div class="space-y-2 max-w-sm">
|
||||
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<button onclick="changePassword()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand Settings -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">品牌</h2>
|
||||
<div id="brandSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- API Key (read-only with copy) -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">API 密钥</h2>
|
||||
<div id="apiKeySection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Thresholds -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">告警阈值 (%)</h2>
|
||||
<div id="alertSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Infrastructure -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">基础设施</h2>
|
||||
<div id="infraSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
@@ -34,11 +105,11 @@
|
||||
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<div id="saveMsg" class="hidden mt-4 text-center text-green-400 text-sm">已保存</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
||||
<script>
|
||||
initLayout('settings');
|
||||
const SECTIONS={
|
||||
@@ -48,7 +119,11 @@
|
||||
telegram:{keys:['telegram_bot_token','telegram_chat_id'],labels:{telegram_bot_token:'Bot Token',telegram_chat_id:'Chat ID'}},
|
||||
};
|
||||
let _allSettings={};
|
||||
let _currentAdminId=null;
|
||||
let _totpSecret='';
|
||||
let _totpUri='';
|
||||
|
||||
// ── Load settings + admin info ──
|
||||
async function loadSettings(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/settings/');if(!r)return;
|
||||
@@ -73,16 +148,243 @@
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// API Key section (read-only + copy)
|
||||
renderApiKeySection();
|
||||
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function renderApiKeySection(){
|
||||
const val=_allSettings['api_key']||'';
|
||||
const isMasked=val.includes('***')||val.includes('...');
|
||||
const el=document.getElementById('apiKeySection');
|
||||
if(isMasked){
|
||||
el.innerHTML=`
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
|
||||
<span id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none">${esc(val)}</span>
|
||||
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||||
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
<span class="text-slate-600 text-xs">🔒 不可修改</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||||
}else{
|
||||
el.innerHTML=`
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
|
||||
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all">${esc(val)}</code>
|
||||
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
let _apiKeyRaw='';
|
||||
|
||||
async function toggleApiKeyVisibility(){
|
||||
const btn=document.getElementById('apiKeyToggleBtn');
|
||||
const display=document.getElementById('apiKeyDisplay');
|
||||
if(btn.textContent.trim()==='显示'){
|
||||
try{
|
||||
const r=await apiFetch(API+'/settings/api-key/reveal',{method:'POST'});if(!r)return;
|
||||
const data=await r.json();
|
||||
_apiKeyRaw=data.value||'';
|
||||
display.textContent=_apiKeyRaw||'(空)';
|
||||
display.className='flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all';
|
||||
btn.textContent='隐藏';
|
||||
}catch(e){}
|
||||
}else{
|
||||
display.textContent=_allSettings['api_key']||'';
|
||||
display.className='flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none';
|
||||
btn.textContent='显示';
|
||||
_apiKeyRaw='';
|
||||
}
|
||||
}
|
||||
|
||||
function copyApiKey(){
|
||||
if(_apiKeyRaw){copyText(_apiKeyRaw);return}
|
||||
// Read from display element (works for both masked and non-masked states)
|
||||
const display=document.getElementById('apiKeyDisplay');
|
||||
const text=display.textContent||'';
|
||||
if(text&&text!=='(空)'&&!text.includes('***')&&!text.includes('...')){
|
||||
copyText(text);
|
||||
}else{
|
||||
// Need to reveal first
|
||||
toast('info','请先点击"显示"按钮查看完整密钥');
|
||||
}
|
||||
}
|
||||
|
||||
function copyTotpSecret(){
|
||||
const el=document.getElementById('totpSecretDisplay');
|
||||
copyText(el.textContent);
|
||||
}
|
||||
|
||||
function copyText(text){
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(text).then(()=>toast('success','已复制到剪贴板')).catch(()=>fallbackCopy(text));
|
||||
}else{
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text){
|
||||
const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';
|
||||
document.body.appendChild(ta);ta.select();
|
||||
try{document.execCommand('copy');toast('success','已复制到剪贴板')}catch(e){toast('error','复制失败')}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
async function saveSetting(key){
|
||||
const v=document.getElementById('set_'+key).value;
|
||||
const r=await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:v})});
|
||||
if(r&&r.ok)toast('success','设置已保存');else toast('error','保存失败');
|
||||
}
|
||||
|
||||
// ── TOTP Management ──
|
||||
async function loadTotpStatus(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/me');if(!r)return;
|
||||
const admin=await r.json();
|
||||
_currentAdminId=admin.id;
|
||||
updateTotpUI(admin.totp_enabled);
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function updateTotpUI(enabled){
|
||||
const badge=document.getElementById('totpStatusBadge');
|
||||
const setupArea=document.getElementById('totpSetupArea');
|
||||
const enabledArea=document.getElementById('totpEnabledArea');
|
||||
if(enabled){
|
||||
badge.textContent='已启用';
|
||||
badge.className='ml-2 text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400';
|
||||
setupArea.classList.add('hidden');
|
||||
enabledArea.classList.remove('hidden');
|
||||
}else{
|
||||
badge.textContent='未启用';
|
||||
badge.className='ml-2 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500';
|
||||
setupArea.classList.remove('hidden');
|
||||
enabledArea.classList.add('hidden');
|
||||
resetTotpSteps();
|
||||
}
|
||||
}
|
||||
|
||||
function resetTotpSteps(){
|
||||
document.getElementById('totpIdleActions').classList.remove('hidden');
|
||||
document.getElementById('totpStep1').classList.add('hidden');
|
||||
document.getElementById('totpVerifyCode').value='';
|
||||
}
|
||||
|
||||
async function startTotpSetup(){
|
||||
if(!_currentAdminId){toast('error','未获取用户信息');return}
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/totp/setup',{
|
||||
method:'POST',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({admin_id:_currentAdminId})
|
||||
});
|
||||
if(!r)return;
|
||||
const data=await r.json();
|
||||
if(!data.success){toast('error',data.reason||'设置失败');return}
|
||||
|
||||
_totpSecret=data.secret;
|
||||
_totpUri=data.uri;
|
||||
|
||||
// Show secret
|
||||
document.getElementById('totpSecretDisplay').textContent=data.secret;
|
||||
document.getElementById('totpIdleActions').classList.add('hidden');
|
||||
document.getElementById('totpStep1').classList.remove('hidden');
|
||||
|
||||
// Generate QR code
|
||||
try{
|
||||
new QRious({
|
||||
element:document.getElementById('totpQrCanvas'),
|
||||
value:data.uri,
|
||||
size:200,
|
||||
backgroundAlpha:0,
|
||||
foreground:'#e2e8f0',
|
||||
level:'M'
|
||||
});
|
||||
}catch(e){
|
||||
// QRious not loaded — fallback to text display
|
||||
document.getElementById('totpQrCanvas').style.display='none';
|
||||
}
|
||||
}catch(e){toast('error','TOTP设置请求失败')}
|
||||
}
|
||||
|
||||
async function verifyAndEnableTotp(){
|
||||
const code=document.getElementById('totpVerifyCode').value.trim();
|
||||
if(code.length!==6||!/^\d{6}$/.test(code)){toast('warning','请输入6位数字验证码');return}
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/totp/enable',{
|
||||
method:'POST',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({admin_id:_currentAdminId,totp_code:code})
|
||||
});
|
||||
if(!r)return;
|
||||
const data=await r.json();
|
||||
if(data.success){
|
||||
toast('success','TOTP已启用!下次登录需要验证码');
|
||||
updateTotpUI(true);
|
||||
}else{
|
||||
toast('error',data.message||data.reason||'验证码错误');
|
||||
}
|
||||
}catch(e){toast('error','启用失败')}
|
||||
}
|
||||
|
||||
async function disableTotp(){
|
||||
if(!confirm('确定要禁用双因素认证?禁用后登录将不再需要验证码。'))return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/totp/disable',{
|
||||
method:'POST',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({admin_id:_currentAdminId})
|
||||
});
|
||||
if(!r)return;
|
||||
const data=await r.json();
|
||||
if(data.success){
|
||||
toast('success','TOTP已禁用');
|
||||
updateTotpUI(false);
|
||||
}else{
|
||||
toast('error','禁用失败');
|
||||
}
|
||||
}catch(e){toast('error','禁用请求失败')}
|
||||
}
|
||||
|
||||
function cancelTotpSetup(){
|
||||
resetTotpSteps();
|
||||
}
|
||||
|
||||
// ── Change Password ──
|
||||
async function changePassword(){
|
||||
const current=document.getElementById('currentPassword').value;
|
||||
const newPw=document.getElementById('newPassword').value;
|
||||
const confirm=document.getElementById('confirmPassword').value;
|
||||
|
||||
if(!current||!newPw||!confirm){toast('warning','请填写所有密码字段');return}
|
||||
if(newPw.length<6){toast('warning','新密码至少6位');return}
|
||||
if(newPw!==confirm){toast('warning','两次输入的新密码不一致');return}
|
||||
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/password',{
|
||||
method:'PUT',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({current_password:current,new_password:newPw})
|
||||
});
|
||||
if(!r){toast('error','请求失败');return}
|
||||
if(r.ok){
|
||||
const data=await r.json();
|
||||
toast('success','密码已修改');
|
||||
document.getElementById('currentPassword').value='';
|
||||
document.getElementById('newPassword').value='';
|
||||
document.getElementById('confirmPassword').value='';
|
||||
}else{
|
||||
const err=await r.json().catch(()=>({}));
|
||||
toast('error',err.detail||'修改失败');
|
||||
}
|
||||
}catch(e){toast('error','修改请求失败')}
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
// ── Init ──
|
||||
loadSettings();
|
||||
loadTotpStatus();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
if (ev.code === 4001) {
|
||||
term.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n');
|
||||
setTimeout(() => location.href = '/app/login.html', 2000);
|
||||
} else if (ev.code === 4003) {
|
||||
term.write('\r\n\x1b[31m⚠ 授权失败: ' + (ev.reason || '服务器SSH凭据未配置') + '\x1b[0m\r\n');
|
||||
} else if (ev.code === 4004) {
|
||||
term.write('\r\n\x1b[31m⚠ 服务器不存在\x1b[0m\r\n');
|
||||
} else if (ev.code !== 1000) {
|
||||
|
||||
Reference in New Issue
Block a user