credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
408 lines
19 KiB
JavaScript
408 lines
19 KiB
JavaScript
/* 全局响应式状态与工具函数 */
|
||
const { reactive, computed } = Vue;
|
||
const Api = window.VpsApi;
|
||
const Fmt = window.VpsFmt;
|
||
|
||
/* ---------------- 全局状态 ---------------- */
|
||
const store = reactive({
|
||
view: 'dashboard',
|
||
assets: [],
|
||
providers: [],
|
||
accounts: [],
|
||
subdomains: [],
|
||
siteCerts: [],
|
||
overview: {},
|
||
expiring: [],
|
||
serverMetrics: {},
|
||
search: '',
|
||
filterType: '',
|
||
filterStatus: '',
|
||
filterProvider: '', // 按平台筛选(slug),从平台页「关联 N 个资产」跳转而来
|
||
assetTab: '', // 资产页 TAB:'' 全部 / vps / domain / cloudflare / subscription(付费资产) / ai_agent
|
||
monitorTab: '', // 监控中心 TAB:同上
|
||
dark: localStorage.getItem('vps_dark') === '1',
|
||
loading: false,
|
||
error: '',
|
||
assetModal: { show: false, editing: null, form: null },
|
||
providerModal: { show: false, editing: null, form: null },
|
||
accountModal: { show: false, editing: null, form: null },
|
||
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
|
||
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
|
||
// 凭据库(密码 + 2FA):全量列表,搜索/过滤交给服务端
|
||
credentials: [],
|
||
credentialModal: { show: false, editing: null, form: null },
|
||
credentialSaving: false, // 保存锁,防双击重复提交
|
||
vaultSearch: '',
|
||
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
|
||
});
|
||
|
||
function applyDark() {
|
||
document.documentElement.classList.toggle('dark', store.dark);
|
||
localStorage.setItem('vps_dark', store.dark ? '1' : '0');
|
||
}
|
||
|
||
/* ---------------- 表单工厂 ---------------- */
|
||
function emptyAssetForm() {
|
||
return {
|
||
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly', renew_url: '', cancel_url: '',
|
||
account_id: null, expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
|
||
status: 'active', is_archived: false, remark: '',
|
||
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
|
||
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
|
||
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
|
||
cloudflare_detail: { account_email: '', sub_type: 'zone', sub_name: '', zone_name: '', status: '' },
|
||
};
|
||
}
|
||
|
||
function buildAssetPayload(f) {
|
||
const p = {
|
||
name: f.name, asset_type: f.asset_type, provider: f.provider,
|
||
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
|
||
renew_url: f.renew_url || null, cancel_url: f.cancel_url || null,
|
||
account_id: f.account_id || null, expiry_date: f.expiry_date || null,
|
||
auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
|
||
status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
|
||
};
|
||
if (f.asset_type === 'vps') {
|
||
const v = f.vps_detail;
|
||
p.vps_detail = { ip_address: v.ip_address, tailscale_ip: v.tailscale_ip || null, region: v.region || null, os: v.os || null, cpu_cores: Number(v.cpu_cores) || 1, memory_gb: Number(v.memory_gb) || 1, disk_gb: Number(v.disk_gb) || 20, bandwidth_gb: v.bandwidth_gb ? Number(v.bandwidth_gb) : null, ssh_port: Number(v.ssh_port) || 22, panel_url: v.panel_url || null, ssh_user: v.ssh_user || null, login_method: v.login_method || 'key', ssh_key: v.ssh_key || null, password: v.password || null, purpose: v.purpose || null };
|
||
} else if (f.asset_type === 'domain') {
|
||
const d = f.domain_detail;
|
||
p.domain_detail = { domain_name: d.domain_name, registrar: d.registrar || null, dns_provider: d.dns_provider || null, cloudflare_account: d.cloudflare_account || null, is_using: !!d.is_using, redirect_target: d.redirect_target || null, bind_asset_id: d.bind_asset_id || null };
|
||
} else if (f.asset_type === 'ai_agent') {
|
||
const a = f.ai_detail;
|
||
p.ai_detail = { provider: a.provider, api_key: a.api_key || null, plan: a.plan || null, balance: a.balance ? Number(a.balance) : null, currency: a.currency || 'USD', monthly_usage: a.monthly_usage ? Number(a.monthly_usage) : null, monthly_limit: a.monthly_limit ? Number(a.monthly_limit) : null };
|
||
} else if (f.asset_type === 'cloudflare') {
|
||
const c = f.cloudflare_detail;
|
||
p.cloudflare_detail = { account_email: c.account_email || null, sub_type: c.sub_type || 'zone', sub_name: c.sub_name || null, zone_name: c.zone_name || null, status: c.status || null };
|
||
}
|
||
return p;
|
||
}
|
||
|
||
/* ---------------- 数据加载 ---------------- */
|
||
async function loadAssets() {
|
||
const params = new URLSearchParams();
|
||
if (store.filterType) params.set('asset_type', store.filterType);
|
||
if (store.filterStatus) params.set('status', store.filterStatus);
|
||
if (store.filterProvider) params.set('provider', store.filterProvider);
|
||
if (store.search) params.set('q', store.search);
|
||
params.set('sort', 'expiry_date'); params.set('order', 'asc');
|
||
store.assets = await Api.get('/assets?' + params.toString());
|
||
}
|
||
async function loadProviders() { store.providers = await Api.get('/providers'); }
|
||
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
|
||
async function loadCredentials() {
|
||
const params = new URLSearchParams();
|
||
if (store.vaultSearch) params.set('q', store.vaultSearch);
|
||
if (store.vaultOtpOnly) params.set('has_otp', 'true');
|
||
store.credentials = await Api.get('/credentials?' + params.toString());
|
||
}
|
||
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
|
||
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
|
||
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
|
||
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
|
||
async function loadAll() {
|
||
store.loading = true; store.error = '';
|
||
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
|
||
catch (e) { store.error = e.message; } finally { store.loading = false; }
|
||
}
|
||
|
||
/* ---------------- 资产操作 ---------------- */
|
||
function openAssetCreate(presetType) {
|
||
const form = emptyAssetForm();
|
||
if (presetType) form.asset_type = presetType;
|
||
store.assetModal = { show: true, editing: null, form };
|
||
}
|
||
function openAICreate(provider) {
|
||
const form = emptyAssetForm();
|
||
form.asset_type = 'ai_agent';
|
||
if (provider) {
|
||
form.provider = provider;
|
||
form.ai_detail.provider = provider;
|
||
const p = store.providers.find(x => x.slug === provider);
|
||
if (p) form.provider_id = p.id;
|
||
}
|
||
store.assetModal = { show: true, editing: null, form };
|
||
}
|
||
function openAssetEdit(a) {
|
||
const form = emptyAssetForm();
|
||
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'renew_url', 'cancel_url', 'account_id', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
|
||
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
|
||
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
|
||
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
|
||
if (a.cloudflare_detail) Object.assign(form.cloudflare_detail, a.cloudflare_detail);
|
||
form.vps_detail.ssh_key = ''; form.vps_detail.password = '';
|
||
store.assetModal = { show: true, editing: a.id, form };
|
||
}
|
||
async function saveAsset() {
|
||
store.error = '';
|
||
const f = store.assetModal.form;
|
||
try {
|
||
const payload = buildAssetPayload(f);
|
||
if (store.assetModal.editing) await Api.put('/assets/' + store.assetModal.editing, payload);
|
||
else await Api.post('/assets', payload);
|
||
store.assetModal.show = false;
|
||
await loadAll();
|
||
} catch (e) { store.error = e.message; }
|
||
}
|
||
async function deleteAsset(a) {
|
||
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
|
||
store.error = '';
|
||
try { await Api.del('/assets/' + a.id); await loadAll(); }
|
||
catch (e) { store.error = e.message; }
|
||
}
|
||
|
||
/* ---------------- 平台操作 ---------------- */
|
||
function openProviderCreate() {
|
||
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '' } };
|
||
}
|
||
function openProviderEdit(p) {
|
||
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '' } };
|
||
}
|
||
async function saveProvider() {
|
||
store.error = '';
|
||
const f = store.providerModal.form;
|
||
// 凭证已下沉到账号层,平台不再携带 api_config
|
||
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null };
|
||
try {
|
||
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
|
||
else await Api.post('/providers', payload);
|
||
store.providerModal.show = false;
|
||
await loadProviders();
|
||
} catch (e) { store.error = e.message; }
|
||
}
|
||
async function seedProviders() {
|
||
store.error = '';
|
||
try { const r = await Api.post('/providers/seed', {}); await loadProviders(); alert('已初始化预设平台,新增 ' + r.added + ' 个'); }
|
||
catch (e) { store.error = e.message; }
|
||
}
|
||
async function deleteProvider(p) {
|
||
if (!confirm('确认删除平台「' + p.name + '」?')) return;
|
||
try { await Api.del('/providers/' + p.id); await loadProviders(); }
|
||
catch (e) { store.error = e.message; }
|
||
}
|
||
|
||
/* ---------------- 账号操作 ---------------- */
|
||
function openAccountsView(providerSlug) {
|
||
store.accountsModal = { show: true, providerSlug: providerSlug || null };
|
||
}
|
||
function openAccountCreate(platform) {
|
||
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '', login_user: '', login_password: '', api_config: '' } };
|
||
}
|
||
function openAccountEdit(a) {
|
||
// login_password / api_config 留空 = 不修改原凭证
|
||
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
|
||
}
|
||
async function saveAccount() {
|
||
// 提交锁:双击/网络慢时重复点击会发出两次请求,第二次必然重名 400
|
||
if (store.accountSaving) return;
|
||
store.error = '';
|
||
store.accountSaving = true;
|
||
const f = store.accountModal.form;
|
||
const payload = {
|
||
name: f.name, platform: f.platform || null, remark: f.remark || null,
|
||
login_user: f.login_user || null,
|
||
login_password: f.login_password || null,
|
||
api_config: f.api_config || null,
|
||
};
|
||
try {
|
||
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
|
||
else await Api.post('/accounts', payload);
|
||
store.accountModal.show = false;
|
||
// 重命名会同步更新资产端引用,需一并刷新资产
|
||
await Promise.all([loadAccounts(), loadAssets()]);
|
||
} catch (e) { store.error = e.message; }
|
||
finally { store.accountSaving = false; }
|
||
}
|
||
async function deleteAccount(a) {
|
||
let msg = '确认删除账号「' + a.name + '」?';
|
||
if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。';
|
||
if (!confirm(msg)) return;
|
||
store.error = '';
|
||
try { await Api.del('/accounts/' + a.id); await loadAccounts(); }
|
||
catch (e) { store.error = e.message; }
|
||
}
|
||
|
||
/* ---------------- 凭据库(密码 + 2FA) ---------------- */
|
||
function emptyCredentialForm() {
|
||
return {
|
||
site: '', username: '', login_type: 'password', oauth_provider: '',
|
||
password: '', url: '', note: '', otp_secret: '', otp_code: '',
|
||
};
|
||
}
|
||
function openCredentialCreate(preset) {
|
||
const form = emptyCredentialForm();
|
||
if (preset) Object.assign(form, preset);
|
||
store.credentialModal = { show: true, editing: null, form };
|
||
}
|
||
function openCredentialEdit(c) {
|
||
// password / otp_secret 留空 = 不修改(与账号凭证惯例一致)
|
||
store.credentialModal = {
|
||
show: true, editing: c.id,
|
||
form: {
|
||
site: c.site, username: c.username || '', login_type: c.login_type,
|
||
oauth_provider: c.oauth_provider || '', password: '', url: c.url || '',
|
||
note: c.note || '', otp_secret: '', otp_code: '',
|
||
},
|
||
};
|
||
}
|
||
async function saveCredential() {
|
||
// 提交锁:双击/网络慢时重复点击会发出两次请求,造成重复条目
|
||
if (store.credentialSaving) return;
|
||
store.error = '';
|
||
store.credentialSaving = true;
|
||
const f = store.credentialModal.form;
|
||
const editing = store.credentialModal.editing;
|
||
const payload = {
|
||
site: f.site, username: f.username || null, login_type: f.login_type,
|
||
oauth_provider: f.oauth_provider || null, url: f.url || null, note: f.note || null,
|
||
};
|
||
try {
|
||
if (editing) {
|
||
// 密码留空 = 不修改(后端 None 语义),填了才提交
|
||
if (f.password) payload.password = f.password;
|
||
await Api.put('/credentials/' + editing, payload);
|
||
// 2FA 走独立接口(secret + 当前动态码服务端校验)
|
||
if (f.otp_secret) {
|
||
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code });
|
||
}
|
||
} else {
|
||
payload.password = f.password || null;
|
||
if (f.otp_secret) { payload.otp_secret = f.otp_secret; payload.otp_code = f.otp_code || null; }
|
||
await Api.post('/credentials', payload);
|
||
}
|
||
store.credentialModal.show = false;
|
||
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
|
||
await Promise.all([loadCredentials(), loadAccounts()]);
|
||
} catch (e) { store.error = e.message; }
|
||
finally { store.credentialSaving = false; }
|
||
}
|
||
async function deleteCredential(c) {
|
||
const label = c.site + (c.username ? ' · ' + c.username : '');
|
||
if (!confirm('确认删除凭据「' + label + '」?此操作不可恢复。')) return;
|
||
store.error = '';
|
||
try {
|
||
await Api.del('/credentials/' + c.id);
|
||
await Promise.all([loadCredentials(), loadAccounts()]);
|
||
} catch (e) { store.error = e.message; }
|
||
}
|
||
async function unbindCredentialOtp(c) {
|
||
if (!confirm('确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。')) return;
|
||
store.error = '';
|
||
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
|
||
catch (e) { store.error = e.message; }
|
||
}
|
||
|
||
/* ---------------- 复制与轻提示(toast) ---------------- */
|
||
const toast = Vue.reactive({ show: false, text: '', _timer: null });
|
||
function showToast(text) {
|
||
toast.text = text;
|
||
toast.show = true;
|
||
if (toast._timer) clearTimeout(toast._timer);
|
||
toast._timer = setTimeout(() => { toast.show = false; }, 1600);
|
||
}
|
||
function copyText(text, label) {
|
||
const done = () => showToast('✓ ' + (label || '内容') + '已复制');
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(done).catch(() => _fallbackCopy(text, done));
|
||
} else { _fallbackCopy(text, done); }
|
||
}
|
||
function _fallbackCopy(text, done) {
|
||
// 非安全上下文(http 局域网)无 clipboard API,降级用 execCommand
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try { document.execCommand('copy'); done(); } catch (e) { showToast('复制失败,请手动选择'); }
|
||
document.body.removeChild(ta);
|
||
}
|
||
async function copyCredentialPassword(c) {
|
||
store.error = '';
|
||
try {
|
||
const r = await Api.get('/credentials/' + c.id + '/password');
|
||
copyText(r.password, '密码');
|
||
} catch (e) { store.error = e.message; }
|
||
}
|
||
async function copyAccountPassword(a) {
|
||
// 账号侧复制:后端已重定向到关联凭据,接口语义不变
|
||
store.error = '';
|
||
try {
|
||
const r = await Api.get('/accounts/' + a.id + '/password');
|
||
copyText(r.password, '密码');
|
||
} catch (e) { store.error = e.message; }
|
||
}
|
||
|
||
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
|
||
// otpState[credential_id] = { code, expiresIn, loading, timer };secret 永不下发到前端
|
||
const otpState = Vue.reactive({});
|
||
async function fetchOtp(id) {
|
||
// 先经 Proxy 创建占位对象,再取回响应式代理。
|
||
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
|
||
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
|
||
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
|
||
const s = otpState[id];
|
||
s.loading = true;
|
||
try {
|
||
const r = await Api.get('/credentials/' + id + '/otp');
|
||
s.code = r.code;
|
||
s.expiresIn = r.expires_in;
|
||
if (s.timer) clearInterval(s.timer);
|
||
// 本地逐秒倒计时,归零自动拉下一个码(不做秒级轮询)
|
||
s.timer = setInterval(() => {
|
||
s.expiresIn--;
|
||
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
|
||
}, 1000);
|
||
} catch (e) { store.error = e.message; }
|
||
finally { s.loading = false; }
|
||
}
|
||
function stopOtp(id) {
|
||
const s = otpState[id];
|
||
if (s && s.timer) { clearInterval(s.timer); s.timer = null; }
|
||
}
|
||
function stopAllOtp() {
|
||
for (const id of Object.keys(otpState)) stopOtp(Number(id));
|
||
}
|
||
|
||
/* ---------------- 平台页跳转:按平台查看资产 ---------------- */
|
||
function openAssetsByProvider(p) {
|
||
store.filterProvider = p.slug;
|
||
store.assetTab = '';
|
||
store.filterType = '';
|
||
if (window.location.hash !== '#assets') window.location.hash = '#assets';
|
||
loadAssets();
|
||
}
|
||
function clearProviderFilter() {
|
||
store.filterProvider = '';
|
||
loadAssets();
|
||
}
|
||
|
||
/* ---------------- 服务器监控 ---------------- */
|
||
async function viewServer(a) {
|
||
try {
|
||
const [metrics, info, checks, security] = await Promise.all([
|
||
Api.get('/monitor/' + a.id + '/metrics?limit=200'),
|
||
Api.get('/monitor/' + a.id + '/info'),
|
||
Api.get('/monitor/' + a.id + '/security'),
|
||
Api.get('/monitor/' + a.id + '/security-score'),
|
||
]);
|
||
store.serverMetrics = { asset: a, metrics, info, checks, security };
|
||
} catch (e) { store.serverMetrics = { asset: a, metrics: [], info: null, checks: [], security: null }; }
|
||
// 通过 hash 路由切换视图,保证刷新/分享链接后能回到 servers 视图
|
||
if (window.location.hash !== '#servers') {
|
||
window.location.hash = '#servers';
|
||
} else {
|
||
store.view = 'servers';
|
||
}
|
||
}
|
||
|
||
/* ---------------- 设置 ---------------- */
|
||
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
|
||
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
|
||
|
||
function normalizedMonthly(cost, cycle) {
|
||
if (!cost) return 0;
|
||
if (cycle === 'yearly') return cost / 12;
|
||
if (cycle === 'quarterly') return cost / 3;
|
||
return cost;
|
||
}
|