This commit is contained in:
2026-07-20 22:01:44 +08:00 Unverified
parent 76a1b66f53
commit 1287544c41
17 changed files with 777 additions and 21 deletions
+44 -2
View File
@@ -93,6 +93,18 @@ async function refreshSession() {
state.scopeLabel = session.scopeLabel || '';
}
async function finishLogin(data) {
state.user = data.user;
await refreshSession();
closeModal();
toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`);
navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`);
}
function showRecoveryCodes(codes) {
setModal(`<div class="modal-head"><div><span>RECOVERY CODES</span><h2>保存一次性恢复码</h2><p>手机丢失或验证器不可用时,可使用其中一个恢复码登录。每个只能使用一次。</p></div></div><div class="recovery-code-sheet" data-recovery-codes>${codes.map(code => `<code>${h(code)}</code>`).join('')}</div><div class="security-warning">请立即复制并离线保存。关闭后系统不会再次显示这些恢复码。</div><div class="modal-foot"><button class="ghost-button" type="button" data-action="copy-recovery-codes">复制全部</button><button class="solid-button" type="button" data-action="close-modal-refresh">我已安全保存</button></div>`);
}
document.addEventListener('click', async event => {
const routeTarget = event.target.closest('[data-route]');
if (routeTarget) {
@@ -105,6 +117,17 @@ document.addEventListener('click', async event => {
const action = target.dataset.action;
try {
if (action === 'close-modal') return closeModal();
if (action === 'close-modal-refresh') { closeModal(); return renderRoute(); }
if (action === 'copy-recovery-codes') {
const codes = [...document.querySelectorAll('[data-recovery-codes] code')].map(item => item.textContent).join('\n');
await navigator.clipboard.writeText(codes);
return toast('恢复码已复制', '请保存到可信的离线位置');
}
if (action === 'copy-totp-secret') {
const secret = document.querySelector('[data-totp-secret]')?.textContent.replace(/\s/g, '') || '';
await navigator.clipboard.writeText(secret);
return toast('手动密钥已复制');
}
if (action === 'retry') return renderRoute();
if (action === 'open-sidebar') return document.querySelector('#portalSidebar')?.classList.add('open');
if (action === 'close-sidebar') return document.querySelector('#portalSidebar')?.classList.remove('open');
@@ -392,8 +415,12 @@ document.addEventListener('submit', async event => {
const kind = form.dataset.form;
if (kind === 'login') {
const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) });
state.user = data.user; await refreshSession();
toast('登录成功', `欢迎,${data.user.displayName}`); navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`);
if (data.requiresTotp) {
setModal(`<div class="modal-head"><div><span>SECOND STEP</span><h2>输入动态验证码</h2><p>打开验证器应用,输入当前显示的 6 位验证码;也可以使用一个恢复码。</p></div><button data-action="close-modal">×</button></div><form class="modal-form totp-login-form" data-form="totp-login"><input type="hidden" name="challenge" value="${h(data.challenge)}"><label><span>动态验证码或恢复码</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required autofocus placeholder="000000"></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">返回</button><button type="submit" class="solid-button">验证并登录</button></div></form>`);
} else await finishLogin(data);
} else if (kind === 'totp-login') {
const data = await api('/api/auth/login/totp', { method: 'POST', body: formObject(form) });
await finishLogin(data);
} else if (kind === 'register') {
const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) });
setModal(`<div class="modal-head"><div><span>CANDIDATE NUMBER</span><h2>请保存你的报名号</h2><p>该号码就是长期使用的考生账户。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>固定报名号</span><strong>${h(data.registrationNumber)}</strong><p>以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。</p></div><div class="modal-foot"><button class="solid-button" data-route="login">前往登录</button></div>`);
@@ -407,6 +434,21 @@ document.addEventListener('submit', async event => {
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
await api('/api/auth/change-password', { method: 'POST', body });
form.reset(); toast('密码修改成功', '下次登录请使用新密码');
} else if (kind === 'totp-setup') {
const data = await api('/api/auth/totp/setup', { method: 'POST', body: formObject(form) });
setModal(`<div class="modal-head"><div><span>AUTHENTICATOR SETUP</span><h2>扫描二维码</h2><p>在验证器应用中添加账户,然后输入应用显示的 6 位验证码完成绑定。</p></div><button data-action="close-modal">×</button></div><div class="totp-setup-grid"><div class="totp-qr"><img src="${h(data.qrCode)}" alt="TOTP 绑定二维码" width="240" height="240"></div><div class="totp-manual"><span>无法扫码?手动输入密钥</span><code data-totp-secret>${h(data.secret.match(/.{1,4}/g)?.join(' ') || data.secret)}</code><button type="button" class="text-button" data-action="copy-totp-secret">复制密钥</button><small>类型:基于时间 · 6 位 · 每 30 秒更新</small></div></div><form class="modal-form totp-confirm-form" data-form="totp-enable"><label><span>验证器中的 6 位验证码</span><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required placeholder="000000"></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">验证并启用</button></div></form>`);
} else if (kind === 'totp-enable') {
const data = await api('/api/auth/totp/enable', { method: 'POST', body: formObject(form) });
state.user = data.user;
showRecoveryCodes(data.recoveryCodes);
} else if (kind === 'totp-recovery-codes') {
const data = await api('/api/auth/totp/recovery-codes', { method: 'POST', body: formObject(form) });
showRecoveryCodes(data.recoveryCodes);
} else if (kind === 'totp-disable') {
await api('/api/auth/totp/disable', { method: 'POST', body: formObject(form) });
await refreshSession();
toast('二次验证已关闭', '账户现在仅使用密码登录');
renderRoute();
} else if (kind === 'candidate-password-reset') {
const body = formObject(form);
const data = await api(`/api/admin/candidates/${body.id}/reset-password`, { method: 'POST' });