credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
170 lines
9.5 KiB
JavaScript
170 lines
9.5 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" 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-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">· {{ 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>
|
|
|
|
<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">授权登录({{ 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 });
|
|
|
|
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;
|
|
if (open && !otpState[c.id]) 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,
|
|
reload: debouncedReload, toggleOtpFilter, copyText,
|
|
copyPwd: copyCredentialPassword, edit: openCredentialEdit,
|
|
del: deleteCredential, unbindOtp: unbindCredentialOtp,
|
|
};
|
|
},
|
|
};
|