69056491a7
fmtTime() was appending 'Z' to all timestamps, causing Invalid Date when the timestamp already had a timezone suffix like +00:00. Now tries new Date(t) first, falls back to new Date(t+'Z'). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
401 lines
26 KiB
HTML
401 lines
26 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="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.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,tab:'db'}">
|
|
<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>
|
|
<button onclick="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
|
</header>
|
|
<!-- Tabs -->
|
|
<div class="bg-slate-900 border-b border-slate-800 px-6 flex">
|
|
<button onclick="switchTab('db')" id="tabDB" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">数据库凭据</button>
|
|
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">密码预设</button>
|
|
<button onclick="switchTab('sshkey')" id="tabSSHKey" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH密钥预设</button>
|
|
</div>
|
|
<main class="flex-1 overflow-y-auto p-6">
|
|
<!-- DB Credentials -->
|
|
<div id="dbSection">
|
|
<div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
|
</div>
|
|
<!-- Password Presets -->
|
|
<div id="presetSection" class="hidden">
|
|
<div id="presetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
|
</div>
|
|
<!-- SSH Key Presets -->
|
|
<div id="sshKeySection" class="hidden">
|
|
<div id="sshKeyPresetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
|
</div>
|
|
|
|
<!-- Add/Edit DB Credential Modal -->
|
|
<div id="credModal" 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-md space-y-3">
|
|
<h2 id="credModalTitle" class="text-white font-semibold text-lg">新建数据库凭据</h2>
|
|
<input type="hidden" id="credEditId" value="">
|
|
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div><label class="block text-slate-400 text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
|
|
<div><label class="block text-slate-400 text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
|
</div>
|
|
<div><label class="block text-slate-400 text-xs mb-1">主机</label><input id="credHost" 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 class="grid grid-cols-2 gap-3">
|
|
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="root"></div>
|
|
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="credPass" type="password" 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="credDb" 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="saveCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add/Edit Password Preset Modal -->
|
|
<div id="presetModal" 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-md space-y-3">
|
|
<h2 id="presetModalTitle" class="text-white font-semibold text-lg">新建密码预设</h2>
|
|
<input type="hidden" id="presetEditId" value="">
|
|
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
|
|
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
|
|
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
|
|
<div class="flex gap-2 pt-2"><button onclick="savePreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add/Edit SSH Key Preset Modal -->
|
|
<div id="sshKeyModal" 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">
|
|
<h2 id="sshKeyModalTitle" class="text-white font-semibold text-lg">新建SSH密钥预设</h2>
|
|
<input type="hidden" id="sshKeyEditId" value="">
|
|
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="sshKeyName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境RSA密钥"></div>
|
|
<div><label class="block text-slate-400 text-xs mb-1">私钥内容</label><textarea id="sshKeyPrivate" rows="6" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(编辑时留空则不修改)"></textarea></div>
|
|
<div><label class="block text-slate-400 text-xs mb-1">公钥内容 <span class="text-slate-600">(可选)</span></label><textarea id="sshKeyPublic" rows="2" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="ssh-rsa AAAA... (可选)"></textarea></div>
|
|
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">私钥将使用 Fernet 加密后存储,不可逆向查看。添加服务器选择此预设时自动解密使用。</div>
|
|
<div class="flex gap-2 pt-2"><button onclick="saveSSHKeyPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSSHKeyModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reveal SSH Key Modal (re-auth required) -->
|
|
<div id="sshKeyRevealModal" 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">
|
|
<h2 class="text-white font-semibold text-lg">查看密钥 — 身份验证</h2>
|
|
<div><label class="block text-slate-400 text-xs mb-1">当前登录密码</label><input id="sshKeyRevealPwd" type="password" 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="doRevealSSHKey()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">验证并查看</button><button onclick="hideSSHKeyRevealModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Revealed SSH Key Content Modal -->
|
|
<div id="sshKeyContentModal" 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">
|
|
<h2 id="sshKeyContentTitle" class="text-white font-semibold text-lg">密钥内容</h2>
|
|
<div>
|
|
<label class="block text-slate-400 text-xs mb-1">私钥</label>
|
|
<textarea id="sshKeyRevealedPrivate" rows="8" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" readonly></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="block text-slate-400 text-xs mb-1">公钥</label>
|
|
<textarea id="sshKeyRevealedPublic" rows="2" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" readonly></textarea>
|
|
</div>
|
|
<div class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-xs text-amber-400">⚠️ 私钥仅在本次查看中显示,关闭后需重新验证身份才能再次查看。</div>
|
|
<div class="flex gap-2 pt-2"><button onclick="hideSSHKeyContentModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<script src="/app/api.js"></script>
|
|
<script src="/app/layout.js"></script>
|
|
<script>
|
|
initLayout('credentials');
|
|
const DB_ICONS={mysql:'🐬',postgresql:'🐘',mariadb:'🐬',mongodb:'🍃',redis:'🔴'};
|
|
const DB_PORTS={mysql:3306,postgresql:5432,mariadb:3306,mongodb:27017,redis:6379};
|
|
|
|
function switchTab(t){
|
|
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
|
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
|
document.getElementById('tabSSHKey').className='px-4 py-3 text-sm border-b-2 '+(t==='sshkey'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
|
document.getElementById('dbSection').classList.toggle('hidden',t!=='db');
|
|
document.getElementById('presetSection').classList.toggle('hidden',t!=='preset');
|
|
document.getElementById('sshKeySection').classList.toggle('hidden',t!=='sshkey');
|
|
if(t==='preset')loadPresets();
|
|
if(t==='sshkey')loadSSHKeyPresets();
|
|
}
|
|
|
|
// ── DB Credentials ──
|
|
async function loadCreds(){
|
|
try{
|
|
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
|
const creds=await r.json();
|
|
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-lg">${DB_ICONS[c.db_type]||'🗄'}</span>
|
|
<div>
|
|
<span class="text-white font-medium">${esc(c.name)}</span>
|
|
<div class="text-slate-500 text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button onclick="editCred(${c.id})" class="text-slate-500 hover:text-white text-xs">编辑</button>
|
|
<button onclick="deleteCred(${c.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无数据库凭据</div>';
|
|
}catch(e){document.getElementById('credsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
|
}
|
|
|
|
function showAddCred(){
|
|
if(!document.getElementById('dbSection').classList.contains('hidden')){
|
|
showAddCredForm();
|
|
}else if(!document.getElementById('presetSection').classList.contains('hidden')){
|
|
showAddPresetForm();
|
|
}else{
|
|
showAddSSHKeyForm();
|
|
}
|
|
}
|
|
|
|
function showAddCredForm(){
|
|
document.getElementById('credModalTitle').textContent='新建数据库凭据';
|
|
document.getElementById('credEditId').value='';
|
|
['credName','credHost','credUser','credPass','credDb'].forEach(id=>document.getElementById(id).value='');
|
|
document.getElementById('credType').value='mysql';
|
|
document.getElementById('credPort').value='3306';
|
|
document.getElementById('credPass').placeholder='输入密码';
|
|
document.getElementById('credModal').classList.remove('hidden');
|
|
}
|
|
|
|
async function editCred(id){
|
|
try{
|
|
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
|
const creds=await r.json();
|
|
const c=creds.find(x=>x.id===id);
|
|
if(!c){toast('error','凭据未找到');return}
|
|
document.getElementById('credModalTitle').textContent='编辑数据库凭据';
|
|
document.getElementById('credEditId').value=id;
|
|
document.getElementById('credName').value=c.name||'';
|
|
document.getElementById('credType').value=c.db_type||'mysql';
|
|
document.getElementById('credPort').value=c.port||3306;
|
|
document.getElementById('credHost').value=c.host||'';
|
|
document.getElementById('credUser').value=c.username||'';
|
|
document.getElementById('credPass').value='';
|
|
document.getElementById('credPass').placeholder='留空则不修改';
|
|
document.getElementById('credDb').value=c.database||'';
|
|
document.getElementById('credModal').classList.remove('hidden');
|
|
}catch(e){toast('error','加载失败')}
|
|
}
|
|
|
|
function showAddPresetForm(){
|
|
document.getElementById('presetModalTitle').textContent='新建密码预设';
|
|
document.getElementById('presetEditId').value='';
|
|
document.getElementById('presetName').value='';
|
|
document.getElementById('presetPass').value='';
|
|
document.getElementById('presetPass').placeholder='输入密码';
|
|
document.getElementById('presetModal').classList.remove('hidden');
|
|
}
|
|
|
|
function editPreset(id,name){
|
|
document.getElementById('presetModalTitle').textContent='编辑密码预设';
|
|
document.getElementById('presetEditId').value=id;
|
|
document.getElementById('presetName').value=name;
|
|
document.getElementById('presetPass').value='';
|
|
document.getElementById('presetPass').placeholder='留空则不修改';
|
|
document.getElementById('presetModal').classList.remove('hidden');
|
|
}
|
|
|
|
function hideCredModal(){document.getElementById('credModal').classList.add('hidden')}
|
|
function hidePresetModal(){document.getElementById('presetModal').classList.add('hidden')}
|
|
|
|
document.getElementById('credType').addEventListener('change',()=>{
|
|
const t=document.getElementById('credType').value;
|
|
document.getElementById('credPort').value=DB_PORTS[t]||3306;
|
|
});
|
|
|
|
async function saveCred(){
|
|
const editId=document.getElementById('credEditId').value;
|
|
const body={
|
|
name:document.getElementById('credName').value,
|
|
db_type:document.getElementById('credType').value,
|
|
host:document.getElementById('credHost').value,
|
|
port:parseInt(document.getElementById('credPort').value)||3306,
|
|
username:document.getElementById('credUser').value,
|
|
password:document.getElementById('credPass').value||null,
|
|
database:document.getElementById('credDb').value||null,
|
|
};
|
|
if(!body.name||!body.host||!body.username){toast('warning','名称、主机、用户名必填');return}
|
|
if(!editId&&!body.password){toast('warning','密码必填');return}
|
|
const url=editId?API+'/scripts/credentials/'+editId:API+'/scripts/credentials';
|
|
const method=editId?'PUT':'POST';
|
|
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
|
if(r&&r.ok)toast('success',editId?'凭据已更新':'凭据已创建');else toast('error','保存失败');
|
|
hideCredModal();loadCreds();
|
|
}
|
|
|
|
async function deleteCred(id){
|
|
if(!confirm('确定删除凭据?'))return;
|
|
const r=await apiFetch(API+'/scripts/credentials/'+id,{method:'DELETE'});
|
|
if(r&&r.ok)toast('success','凭据已删除');else toast('error','删除失败');
|
|
loadCreds();
|
|
}
|
|
|
|
// ── Password Presets ──
|
|
async function loadPresets(){
|
|
try{
|
|
const r=await apiFetch(API+'/presets/');if(!r)return;
|
|
const presets=await r.json();
|
|
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-lg">🔑</span>
|
|
<div>
|
|
<span class="text-white font-medium">${esc(p.name)}</span>
|
|
<div class="text-slate-500 text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button onclick="editPreset(${p.id},this.dataset.name)" data-name="${esc(p.name)}" class="text-slate-500 hover:text-white text-xs">编辑</button>
|
|
<button onclick="deletePreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无密码预设</div>';
|
|
}catch(e){document.getElementById('presetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
|
}
|
|
|
|
async function savePreset(){
|
|
const editId=document.getElementById('presetEditId').value;
|
|
const name=document.getElementById('presetName').value;
|
|
const pw=document.getElementById('presetPass').value;
|
|
if(!name){toast('warning','名称必填');return}
|
|
if(!editId&&!pw){toast('warning','密码必填');return}
|
|
if(editId){
|
|
const body={name};
|
|
if(pw)body.encrypted_pw=pw;
|
|
const r=await apiFetch(API+'/presets/'+editId,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
|
if(r&&r.ok)toast('success','预设已更新');else toast('error','更新失败');
|
|
}else{
|
|
const r=await apiFetch(API+'/presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({name,encrypted_pw:pw})});
|
|
if(r&&r.ok)toast('success','密码预设已创建');else toast('error','创建失败');
|
|
}
|
|
hidePresetModal();loadPresets();
|
|
}
|
|
|
|
async function deletePreset(id){
|
|
if(!confirm('确定删除密码预设?'))return;
|
|
const r=await apiFetch(API+'/presets/'+id,{method:'DELETE'});
|
|
if(r&&r.ok)toast('success','预设已删除');else toast('error','删除失败');
|
|
loadPresets();
|
|
}
|
|
|
|
// ── SSH Key Presets ──
|
|
let _sshKeyRevealId=null;
|
|
|
|
async function loadSSHKeyPresets(){
|
|
try{
|
|
const r=await apiFetch(API+'/ssh-key-presets/');if(!r)return;
|
|
const presets=await r.json();
|
|
document.getElementById('sshKeyPresetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-lg">🔐</span>
|
|
<div>
|
|
<span class="text-white font-medium">${esc(p.name)}</span>
|
|
<div class="text-slate-500 text-xs mt-0.5">${p.public_key?esc(p.public_key.substring(0,40))+'...':'无公钥'} · 创建于 ${fmtTime(p.created_at)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button onclick="revealSSHKey(${p.id})" class="text-brand-light hover:text-white text-xs" title="查看密钥内容(需验证身份)">查看</button>
|
|
<button onclick="editSSHKeyPreset(${p.id},this.dataset.name,this.dataset.pubkey)" data-name="${esc(p.name)}" data-pubkey="${esc(p.public_key||'')}" class="text-slate-500 hover:text-white text-xs">编辑</button>
|
|
<button onclick="deleteSSHKeyPreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无SSH密钥预设</div>';
|
|
}catch(e){document.getElementById('sshKeyPresetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
|
}
|
|
|
|
function showAddSSHKeyForm(){
|
|
document.getElementById('sshKeyModalTitle').textContent='新建SSH密钥预设';
|
|
document.getElementById('sshKeyEditId').value='';
|
|
document.getElementById('sshKeyName').value='';
|
|
document.getElementById('sshKeyPrivate').value='';
|
|
document.getElementById('sshKeyPrivate').placeholder='粘贴 PEM 格式私钥内容';
|
|
document.getElementById('sshKeyPublic').value='';
|
|
document.getElementById('sshKeyModal').classList.remove('hidden');
|
|
}
|
|
|
|
function editSSHKeyPreset(id,name,pubKey){
|
|
document.getElementById('sshKeyModalTitle').textContent='编辑SSH密钥预设';
|
|
document.getElementById('sshKeyEditId').value=id;
|
|
document.getElementById('sshKeyName').value=name||'';
|
|
document.getElementById('sshKeyPrivate').value='';
|
|
document.getElementById('sshKeyPrivate').placeholder='留空则不修改私钥';
|
|
document.getElementById('sshKeyPublic').value=pubKey||'';
|
|
document.getElementById('sshKeyModal').classList.remove('hidden');
|
|
}
|
|
|
|
function hideSSHKeyModal(){document.getElementById('sshKeyModal').classList.add('hidden')}
|
|
|
|
async function saveSSHKeyPreset(){
|
|
const editId=document.getElementById('sshKeyEditId').value;
|
|
const name=document.getElementById('sshKeyName').value;
|
|
const privateKey=document.getElementById('sshKeyPrivate').value;
|
|
const publicKey=document.getElementById('sshKeyPublic').value;
|
|
if(!name){toast('warning','名称必填');return}
|
|
if(!editId&&!privateKey){toast('warning','私钥必填');return}
|
|
const body={name,private_key:privateKey||undefined,public_key:publicKey||undefined};
|
|
if(editId){
|
|
if(!privateKey)delete body.private_key;
|
|
const r=await apiFetch(API+'/ssh-key-presets/'+editId,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
|
if(r&&r.ok)toast('success','密钥预设已更新');else toast('error','更新失败');
|
|
}else{
|
|
const r=await apiFetch(API+'/ssh-key-presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
|
if(r&&r.ok)toast('success','SSH密钥预设已创建');else toast('error','创建失败');
|
|
}
|
|
hideSSHKeyModal();loadSSHKeyPresets();
|
|
}
|
|
|
|
async function deleteSSHKeyPreset(id){
|
|
if(!confirm('确定删除SSH密钥预设?'))return;
|
|
const r=await apiFetch(API+'/ssh-key-presets/'+id,{method:'DELETE'});
|
|
if(r&&r.ok)toast('success','密钥预设已删除');else toast('error','删除失败');
|
|
loadSSHKeyPresets();
|
|
}
|
|
|
|
// ── Reveal SSH Key (re-auth required) ──
|
|
function revealSSHKey(id){
|
|
_sshKeyRevealId=id;
|
|
document.getElementById('sshKeyRevealPwd').value='';
|
|
document.getElementById('sshKeyRevealModal').classList.remove('hidden');
|
|
}
|
|
|
|
function hideSSHKeyRevealModal(){
|
|
document.getElementById('sshKeyRevealModal').classList.add('hidden');
|
|
_sshKeyRevealId=null;
|
|
}
|
|
|
|
async function doRevealSSHKey(){
|
|
const pwd=document.getElementById('sshKeyRevealPwd').value;
|
|
if(!pwd){toast('warning','请输入密码');return}
|
|
const r=await apiFetch(API+'/ssh-key-presets/'+_sshKeyRevealId+'/reveal',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({current_password:pwd})});
|
|
if(!r){hideSSHKeyRevealModal();return}
|
|
if(r.status===400){
|
|
const d=await r.json();
|
|
toast('error',d.detail||'密码错误');
|
|
return;
|
|
}
|
|
if(!r.ok){toast('error','查看失败');hideSSHKeyRevealModal();return}
|
|
const data=await r.json();
|
|
hideSSHKeyRevealModal();
|
|
// Show revealed content
|
|
document.getElementById('sshKeyContentTitle').textContent='密钥内容 — '+esc(data.name);
|
|
document.getElementById('sshKeyRevealedPrivate').value=data.private_key||'';
|
|
document.getElementById('sshKeyRevealedPublic').value=data.public_key||'';
|
|
document.getElementById('sshKeyContentModal').classList.remove('hidden');
|
|
}
|
|
|
|
function hideSSHKeyContentModal(){
|
|
document.getElementById('sshKeyContentModal').classList.add('hidden');
|
|
document.getElementById('sshKeyRevealedPrivate').value='';
|
|
document.getElementById('sshKeyRevealedPublic').value='';
|
|
}
|
|
|
|
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
|
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
|
|
|
loadCreds();
|
|
</script>
|
|
</body></html>
|