Files
Nexus/web/app/settings.html
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

391 lines
20 KiB
HTML

<!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,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 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">
<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">
<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">
<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>
<!-- Telegram -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-white font-medium mb-4">Telegram 通知</h2>
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</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={
brand:{keys:['system_name','system_title'],labels:{system_name:'系统名称',system_title:'页面标题'}},
alert:{keys:['cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold'],labels:{cpu_alert_threshold:'CPU 告警阈值',mem_alert_threshold:'内存告警阈值',disk_alert_threshold:'磁盘告警阈值'}},
infra:{keys:['db_pool_size','db_max_overflow','heartbeat_timeout','redis_url'],labels:{db_pool_size:'连接池大小',db_max_overflow:'最大溢出',heartbeat_timeout:'心跳超时(秒)',redis_url:'Redis URL'}},
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;
const settings=await r.json();
_allSettings={};settings.forEach(s=>_allSettings[s.key]=s.value);
for(const[section,cfg]of Object.entries(SECTIONS)){
const el=document.getElementById(section+'Section');
el.innerHTML=cfg.keys.map(key=>{
const val=_allSettings[key]||'';
const isMasked=val.includes('***')||val.includes('...');
if(isMasked){
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm select-none">${esc(val)}</span>
<span class="text-slate-600 text-xs">🔒 安全锁定</span>
</div>`;
}
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<input id="set_${key}" value="${esc(val)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<button onclick="saveSetting('${key}')" class="px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
</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>