Files
vps-manager/static/js/views/vault.js
T
gouki 61372f7ceb fix(review): 修复全量审查发现的域名到期日链路、AI 资产挂靠迁移与 2FA 取码缺陷
92 文件全量代码审查后的高危/中危修复,每条都带回归测试,并在未修复的 HEAD 上
反向确认过「测试确实会失败」。

- H1 域名同步整批失败:NormalizedDomain.expiry_date 契约声明为字符串,Cloudflare
  适配器把 Registrar 的 ISO 时间戳截成 "YYYY-MM-DD" 直接赋给 Date 列,写库抛
  TypeError;而 _sync_domains 的 commit 在循环外,一个域名就让整批全量回滚
  (Cloudflare 域名实际从未同步成功)。现统一契约:base.py 新增 parse_iso_date,
  适配器产出 date,_sync_domains 写库前再归一一次以兼容仍返回字符串的适配器
- M9 Vultr / DigitalOcean 的域名到期日此前完全没映射,这两家域名永远进不了续费提醒
- M1 同步失败变 500:能力分支写库失败后未回滚,Session 停在 pending-rollback,
  随后更新 last_synced_at 的 commit 抛 PendingRollbackError,真实错误被 500 掩盖
- H2 AI 资产挂靠迁移静默零命中:迁移 SQL 用枚举值 'ai_agent' 匹配 Enum 列,而
  SQLAlchemy Enum 存的是成员名 'AI_AGENT',该分支从未生效(6 个 AI 资产的
  account_id 全为 NULL)。改用成员名并加日志留痕;账号命名沿用步骤 1 的
  「{slug}-默认」约定,避免建出与既有账号语义重复、且没有凭证的空账号
  (dry-run 实测:deepseek 资产复用已有且有凭证的 deepseek-默认)
- H3 2FA 动态码不刷新:otpState 全局常驻、收起只清 timer 不删条目,「不存在才拉」
  的守卫导致再次展开显示上一次的旧码且倒计时冻结在 0。改为每次展开都重取;
  stopOtp 同时清空旧码(避免重展开瞬间闪过已失效的码),并加请求代号,防止
  「请求在途时收起」后返回的响应又重启轮询
- M10 账号弹窗关闭不清理:外层用 v-if 关闭并不卸载组件,onUnmounted 不触发,
  OTP 轮询定时器与明文密码驻留后台。关闭时显式调 closePwd
- M2 提醒发不出去:Telegram 用 parse_mode=HTML,资产名含 < > & 时整条消息被 400
  拒收;纯文本提醒不需要任何标签,去掉 parse_mode,并把拒收正文带回便于排查
- M3 负数天文案:cbb32ea 已修好提醒消息正文,本次补齐仍在直出负数的两处
  (check_renewals.py 输出、设置页预览),后端抽出 renewal_service.day_text 统一,
  前端走 Fmt.countdownText

验证:pytest 94 passed(新增 tests/test_domain_expiry.py、tests/test_notification.py);
本地起服务对 H2 迁移做真实 dry-run(6 个 AI 资产挂到 4 个账号、无重复账号、幂等、
非 AI 资产零影响,迁移前已备份);浏览器端以 otpState 状态断言验证 H3/M10,
设置页续费预览确认已过期项显示「已过期 N 天」。
2026-09-22 22:26:42 +00:00

188 lines
11 KiB
JavaScript

/* 凭据库视图:登录密码 + 2FA 动态码
*
* 数据来自 credentials 表(登录凭据唯一事实源):既包含平台账号迁移/联动过来的
* 条目,也包含自行录入的普通网站。列表以「取用快」为先:搜索、一键复制、
* 展开看动态码;移动端单列卡片,桌面端同布局(避免横向溢出)。
*/
const VaultView = {
template: `
<div class="space-y-3">
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
🔐 凭据库 = 各站点的登录方式(用户名 / 密码 / 2FA / 授权登录)。平台账号的登录密码也统一存在这里,只维护一份,不会两头改。
</div>
<div class="flex gap-2 items-center">
<input v-model="store.vaultSearch" @input="reload" placeholder="搜索站点/用户名/备注…"
class="flex-1 min-w-0 text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<button @click="toggleOtpFilter" class="text-sm px-3 py-1.5 rounded-lg border whitespace-nowrap shrink-0"
:class="store.vaultOtpOnly ? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400'">🛡 2FA</button>
</div>
<p v-if="!store.credentials.length" class="text-xs text-slate-400 py-8 text-center">
{{ store.vaultSearch || store.vaultOtpOnly ? '没有匹配的凭据' : '暂无凭据。点右上「+ 新增」录入网站账号;平台账号填过登录密码的会自动出现在这里。' }}
</p>
<div class="space-y-2">
<div v-for="c in store.credentials" :key="c.id"
class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<!-- 主行 -->
<div class="px-3 py-2.5 flex items-center gap-2">
<button @click="toggle(c)" class="text-slate-400 text-xs w-4 shrink-0">{{ expanded[c.id] ? '▾' : '▸' }}</button>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">
{{ c.site }}
<span v-if="c.has_otp && c.otp_verified" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 align-middle">2FA</span>
<span v-else-if="c.has_otp" title="secret 仅录入未校验,可能抄错;拿到验证码时补验"
class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle">2FA 未验证</span>
<span v-if="c.duplicate" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle"
title="同站点同用户名已有多条,确认是否重复录入">重复?</span>
</div>
<div class="text-xs text-slate-400 truncate">
<span v-if="c.username">{{ c.username }}</span><span v-else class="italic">未填用户名</span>
<span v-if="c.login_type==='oauth'" class="ml-1 text-emerald-600 dark:text-emerald-400">· {{ Fmt.oauthLabel(c.oauth_provider) || '授权' }} 登录</span>
<span v-else-if="c.login_type==='other'" class="ml-1">· 其他登录方式</span>
<span v-if="c.account_name" class="ml-1">· 平台账号</span>
</div>
</div>
<button v-if="c.has_otp" @click="toggle(c, true)"
class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white shrink-0">验证码</button>
<button v-if="c.has_password" @click="copyPwd(c)"
class="text-xs px-2 py-1 rounded-lg border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 shrink-0">复制</button>
</div>
<!-- 展开详情 -->
<div v-if="expanded[c.id]" class="px-3 pb-3 pt-2 border-t border-slate-100 dark:border-slate-800 space-y-2">
<!-- 2FA 动态码:服务端生成,本地倒计时,归零自动刷新 -->
<div v-if="c.has_otp" class="flex items-center gap-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
<div class="font-mono text-2xl tracking-widest text-blue-700 dark:text-blue-300 shrink-0">{{ otpOf(c).code || '······' }}</div>
<div class="min-w-0 flex-1">
<div class="text-[11px] text-slate-500 dark:text-slate-400">{{ otpOf(c).expiresIn }}s 后刷新</div>
<div class="h-1 bg-slate-200 dark:bg-slate-700 rounded mt-1 overflow-hidden">
<div class="h-full bg-blue-500 transition-all duration-1000 ease-linear"
:style="{ width: Math.max(0, otpOf(c).expiresIn) / 30 * 100 + '%' }"></div>
</div>
</div>
<button @click="copyOtp(c)" class="text-xs px-2 py-1 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<!-- 未校验的 2FA:持续提醒并就地补验(不阻塞录入,但也不假装它是可信的) -->
<div v-if="c.has_otp && !c.otp_verified" class="flex items-center gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2">
<span class="text-[11px] text-amber-700 dark:text-amber-300 min-w-0 flex-1 leading-snug">⚠ 此 2FA 只录了 secret、未校验过(万一抄错则码不可用)。下次能拿到验证码时补验一下。</span>
<input v-model="verifyInput[c.id]" @keyup.enter="doVerify(c)" inputmode="numeric" maxlength="6"
placeholder="当前 6 位码" class="w-[6.5rem] shrink-0 text-xs px-2 py-1 rounded border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 font-mono tracking-widest">
<button @click="doVerify(c)" class="text-xs px-2 py-1 rounded bg-amber-600 text-white shrink-0">验证</button>
</div>
<div class="text-xs text-slate-500 space-y-1">
<div v-if="c.url"><a :href="c.url" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline break-all">登录页 ↗</a></div>
<div v-if="c.note" class="whitespace-pre-wrap break-words">{{ c.note }}</div>
<div v-if="c.login_type==='oauth'" class="text-slate-400">授权登录({{ Fmt.oauthDetail(c.oauth_provider) || '未记录来源' }}),本站无独立密码</div>
<div v-if="c.account_name" class="text-slate-400">关联平台账号:{{ c.account_name }}</div>
<div class="text-slate-300 dark:text-slate-600">更新于 {{ fmtTime(c.updated_at) }}</div>
</div>
<div class="flex gap-4 text-xs flex-wrap">
<button v-if="c.has_password" @click="reveal(c)" class="text-amber-600 dark:text-amber-400">查看密码</button>
<button @click="edit(c)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="c.has_otp" @click="unbindOtp(c)" class="text-slate-500 dark:text-slate-400">解绑 2FA</button>
<button @click="del(c)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</div>
<!-- 密码明文弹窗:8 秒自动关闭,减少明文暴露时间 -->
<div v-if="pwd.show" class="fixed inset-0 bg-black/50 z-[70] flex items-center justify-center p-4" @click.self="closePwd">
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.site }}</h4>
<div class="space-y-2 text-sm">
<div v-if="pwd.username" class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">用户名</span>
<span class="font-mono truncate">{{ pwd.username }}</span>
</div>
<div class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">密码</span>
<span class="font-mono break-all">{{ pwd.password }}</span>
</div>
</div>
<div class="flex gap-2 mt-4">
<button @click="copyText(pwd.password, '密码')" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">复制密码</button>
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
</div>
<p class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
</div>
</div>
</div>`,
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, site: '', username: '', password: '', countdown: 0, _timer: null });
// 未验证 2FA 的补验输入(按凭据 id 暂存,失败时保留用户已输入的码)
const verifyInput = Vue.reactive({});
async function doVerify(c) {
if (await verifyCredentialOtp(c, verifyInput[c.id])) verifyInput[c.id] = '';
}
function otpOf(c) {
return otpState[c.id] || { code: '', expiresIn: 0, loading: false };
}
// 展开即拉动态码并开始倒计时;收起时停掉定时器
function toggle(c, forceOpen) {
const open = forceOpen ? true : !expanded[c.id];
expanded[c.id] = open;
if (!c.has_otp) return;
// 每次展开都重取:otpState 是全局的、收起只清 timer 不删条目,
// 若按「不存在才拉」判断,再次展开会显示上一次的旧码且倒计时冻在 0
if (open) fetchOtp(c.id);
if (!open) stopOtp(c.id);
}
async function reveal(c) {
store.error = '';
try {
const r = await Api.get('/credentials/' + c.id + '/password');
pwd.site = c.site;
pwd.username = r.username || '';
pwd.password = r.password;
pwd.show = true;
if (pwd._timer) clearInterval(pwd._timer);
pwd.countdown = 8;
pwd._timer = setInterval(() => {
pwd.countdown--;
if (pwd.countdown <= 0) closePwd();
}, 1000);
} catch (e) { store.error = e.message; }
}
function closePwd() {
pwd.show = false;
pwd.password = '';
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
}
function copyOtp(c) {
const s = otpState[c.id];
if (s && s.code) copyText(s.code, '验证码');
}
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
// 搜索防抖:300ms 内连续输入只发一次请求
let searchTimer = null;
function debouncedReload() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { loadCredentials(); }, 300);
}
function toggleOtpFilter() {
store.vaultOtpOnly = !store.vaultOtpOnly;
loadCredentials();
}
// 离开视图时停掉全部倒计时,避免后台定时器泄漏
Vue.onUnmounted(() => { stopAllOtp(); closePwd(); });
return {
store, Fmt, expanded, pwd, otpOf, toggle, reveal, closePwd, copyOtp, fmtTime,
verifyInput, doVerify,
reload: debouncedReload, toggleOtpFilter, copyText,
copyPwd: copyCredentialPassword, edit: openCredentialEdit,
del: deleteCredential, unbindOtp: unbindCredentialOtp,
};
},
};