First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
+456
View File
@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安全设置 - Cal Display</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--accent-green: #4CAF50;
--accent-red: #E05555;
--radius: 12px;
--input-bg: #242838;
--input-border: rgba(255,255,255,0.14);
--divider: rgba(255,255,255,0.16);
--card-border: rgba(255,255,255,0.10);
--soft-bg: rgba(255,255,255,0.06);
--shadow: 0 4px 24px rgba(0,0,0,0.30);
}
/* 浅色主题覆盖 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: 12px;
--input-bg: #fafafa;
--input-border: #d7dfe8;
--divider: #d7dfe8;
--card-border: transparent;
--soft-bg: #eef2f7;
--shadow: 0 2px 12px rgba(30,40,60,0.08);
}
/* 主题切换动画 */
html.theme-anim, html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease !important;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
min-height: 100vh;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
display: flex; align-items: center; justify-content: center;
}
.setup-box {
width: min(92vw, 500px);
background: var(--card-bg);
border-radius: var(--radius);
padding: 28px 32px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.setup-box h1 { font-size: 22px; margin-bottom: 6px; font-weight: 600; }
.setup-box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; }
.method-card {
border: 2px solid #e0e0e0;
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
transition: border-color 0.2s;
}
.method-card.active { border-color: var(--accent); }
.method-card.done { border-color: var(--accent-green); }
.method-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.method-header h3 { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.method-header .badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.badge-done { background: #e8f5e9; color: #2e7d32; }
.badge-pending { background: #fff3e0; color: #e65100; }
.method-body { font-size: 14px; color: var(--text-dim); line-height: 1.6; }
.method-body .btn {
display: inline-block;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
margin-top: 12px;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-success { background: var(--accent-green); color: #fff; }
.btn-success:hover { opacity: 0.9; }
.btn-secondary { background: #eee; color: var(--text); }
.btn-secondary:hover { opacity: 0.9; }
.totp-setup input { font-size: 24px; letter-spacing: 8px; text-align: center; padding: 10px; width: 100%; border: 1px solid #ddd; border-radius: 8px; font-family: monospace; margin-top: 8px; }
.totp-setup .qr-wrap { text-align: center; margin: 12px 0; }
.totp-setup .qr-wrap img { max-width: 200px; border-radius: 8px; }
.totp-setup .secret-display { font-family: monospace; font-size: 14px; background: #f5f5f5; padding: 8px; border-radius: 6px; text-align: center; margin: 8px 0; word-break: break-all; }
.passkey-status { font-size: 13px; color: var(--text-dim); margin-top: 8px; min-height: 20px; }
.recovery-section { margin-top: 20px; padding: 16px; background: #fff8e1; border-radius: 8px; border: 1px solid #ffe082; display: none; }
.recovery-section.visible { display: block; }
.recovery-section h4 { font-size: 14px; color: #e65100; margin-bottom: 8px; }
.recovery-codes { font-family: monospace; font-size: 14px; line-height: 2; }
.recovery-codes code { display: inline-block; background: #fff; padding: 2px 8px; border-radius: 4px; margin: 2px; border: 1px solid #e0e0e0; }
.finish-section { margin-top: 20px; text-align: center; }
.finish-section .btn { padding: 14px 40px; font-size: 16px; }
.msg { font-size: 13px; color: var(--accent-red); margin-top: 8px; min-height: 20px; }
.success { color: var(--accent-green); }
/* ═══ 主题适配(MFA 设置页)═══ */
body { background: var(--bg); }
.setup-box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
.method-card { border-color: var(--divider); }
.method-card.active { border-color: var(--accent); }
.totp-setup input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
.secret-display { background: var(--soft-bg); }
.btn-secondary { background: var(--soft-bg); color: var(--text); }
.recovery-section { background: rgba(255,200,87,0.10); border-color: rgba(255,200,87,0.30); }
.badge-done { background: rgba(74,175,80,0.18); color: #7bc47f; }
.badge-pending { background: rgba(230,81,0,0.20); color: #ffb27a; }
.theme-light .recovery-section { background: #fff8e1; border-color: #ffe082; }
.theme-light .badge-done { background: #e8f5e9; color: #2e7d32; }
.theme-light .badge-pending { background: #fff3e0; color: #e65100; }
</style>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="setup-box">
<h1>🔐 安全设置</h1>
<p class="subtitle">请至少配置一种双重认证方式以保护你的设备</p>
<!-- Passkey -->
<div class="method-card" id="passkey-card">
<div class="method-header">
<h3>🔑 Passkey (生物/设备密钥)</h3>
<span class="badge badge-pending" id="passkey-badge">未配置</span>
</div>
<div class="method-body">
<p>使用设备内置的安全模块(指纹、面容、PIN 或安全密钥)快速登录。</p>
<button class="btn btn-primary" id="register-passkey-btn">注册 Passkey</button>
<div class="passkey-status" id="passkey-status"></div>
</div>
</div>
<!-- TOTP -->
<div class="method-card" id="totp-card">
<div class="method-header">
<h3>📱 TOTP (身份验证器应用)</h3>
<span class="badge badge-pending" id="totp-badge">未配置</span>
</div>
<div class="method-body">
<p>使用 Google Authenticator、Microsoft Authenticator 等应用扫描二维码。</p>
<button class="btn btn-secondary" id="setup-totp-btn">设置 TOTP</button>
<div id="totp-setup-area" style="display:none;" class="totp-setup">
<div class="qr-wrap" id="totp-qr-wrap"></div>
<div class="secret-display" id="totp-secret-display"></div>
<p style="font-size:13px;color:var(--text-dim);">在应用中添加后,输入显示的 6 位验证码确认:</p>
<input type="text" id="totp-verify-input" maxlength="6" inputmode="numeric" pattern="[0-9]*" placeholder="000000">
<button class="btn btn-success" id="totp-verify-btn">验证并启用</button>
<div id="totp-msg" class="msg"></div>
</div>
</div>
</div>
<!-- Recovery Codes (shown after first MFA configured) -->
<div class="recovery-section" id="recovery-section">
<h4>⚠️ 恢复码(请妥善保管)</h4>
<p style="font-size:13px;color:#e65100;margin-bottom:8px;">每个恢复码仅可使用一次。建议截图或打印保存。</p>
<div class="recovery-codes" id="recovery-codes"></div>
<button class="btn btn-secondary" id="copy-recovery-btn" style="margin-top:8px;">📋 复制</button>
</div>
<!-- Finish -->
<div class="finish-section">
<button class="btn btn-success" id="finish-btn">✅ 完成设置,进入控制面板</button>
<div id="finish-msg" class="msg" style="margin-top:8px;"></div>
</div>
</div>
<script>
(function () {
'use strict';
var passkeyDone = false;
var totpDone = false;
// ─── Check MFA status ──────────────────
function checkMFAStatus() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/mfa/status');
xhr.onload = function () {
if (xhr.status === 200) {
var s = JSON.parse(xhr.responseText);
// Temp accounts must configure ONE login method to activate the account.
if (s.is_temp_account) {
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
document.querySelector('.subtitle').textContent =
'请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
}
if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); }
if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); }
if (s.recovery_codes_remaining > 0) {
showRecoveryCodes();
}
updateFinishBtn();
} else if (xhr.status === 401) {
// Not logged in — redirect to login
location.href = '/login?view=control&next=/mfa-setup';
}
};
xhr.send();
}
checkMFAStatus();
// ─── Passkey ───────────────────────────
function updatePasskeyUI(done) {
var card = document.getElementById('passkey-card');
var badge = document.getElementById('passkey-badge');
if (done) {
card.classList.add('done');
badge.textContent = '✅ 已配置';
badge.className = 'badge badge-done';
document.getElementById('register-passkey-btn').textContent = '重新注册';
}
}
document.getElementById('register-passkey-btn').addEventListener('click', function () {
var statusEl = document.getElementById('passkey-status');
var btn = this;
btn.disabled = true;
statusEl.textContent = '正在准备注册...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/webauthn/register/begin');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status !== 200) {
statusEl.textContent = '❌ 获取注册参数失败';
btn.disabled = false;
return;
}
var options = JSON.parse(xhr.responseText);
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.creation(options)
.then(function (credential) {
credential.user_name = options.user && options.user.name ? options.user.name : 'user';
var verify = new XMLHttpRequest();
verify.open('POST', '/api/auth/webauthn/register/complete');
verify.setRequestHeader('Content-Type', 'application/json');
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
statusEl.textContent = '✅ Passkey 注册成功!';
passkeyDone = true;
updatePasskeyUI(true);
checkRecoveryAndFinish();
if (res.finalized) {
statusEl.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500);
}
} else {
statusEl.textContent = '❌ ' + (res.error || '注册失败');
btn.disabled = false;
}
};
verify.send(JSON.stringify({ credential: credential }));
})
.catch(function (err) {
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
btn.disabled = false;
});
};
xhr.send();
});
// ─── TOTP ──────────────────────────────
function updateTOTPUI(done) {
var card = document.getElementById('totp-card');
var badge = document.getElementById('totp-badge');
if (done) {
card.classList.add('done');
badge.textContent = '✅ 已配置';
badge.className = 'badge badge-done';
document.getElementById('setup-totp-btn').textContent = '重新设置';
}
}
document.getElementById('setup-totp-btn').addEventListener('click', function () {
var area = document.getElementById('totp-setup-area');
if (area.style.display === 'block') {
area.style.display = 'none';
return;
}
area.style.display = 'block';
document.getElementById('setup-totp-btn').textContent = '取消';
// Request TOTP setup from server
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/totp/setup');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status !== 200) return;
var data = JSON.parse(xhr.responseText);
document.getElementById('totp-qr-wrap').innerHTML = '<img src="data:image/png;base64,' + data.qr_base64 + '" alt="TOTP QR Code">';
document.getElementById('totp-secret-display').textContent = '密钥: ' + data.secret;
};
xhr.send();
});
document.getElementById('totp-verify-btn').addEventListener('click', function () {
var code = document.getElementById('totp-verify-input').value;
var msg = document.getElementById('totp-msg');
msg.textContent = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/totp/verify');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
msg.className = 'msg success';
msg.textContent = '✅ TOTP 已启用!';
totpDone = true;
updateTOTPUI(true);
document.getElementById('setup-totp-btn').textContent = '重新设置';
// Show recovery codes
if (res.recovery_codes && res.recovery_codes.length > 0) {
showRecoveryCodesList(res.recovery_codes);
} else {
checkMFAStatus();
}
checkRecoveryAndFinish();
if (res.finalized) {
msg.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500);
}
} else {
msg.textContent = res.error || '验证失败';
}
};
xhr.send(JSON.stringify({ code: code }));
});
// ─── Recovery codes ────────────────────
function showRecoveryCodesList(codes) {
var section = document.getElementById('recovery-section');
var container = document.getElementById('recovery-codes');
section.classList.add('visible');
container.innerHTML = codes.map(function (c) { return '<code>' + c + '</code>'; }).join(' ');
}
function showRecoveryCodes() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/recovery-codes');
xhr.onload = function () {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
if (data.codes && data.codes.length > 0) {
showRecoveryCodesList(data.codes);
}
}
};
xhr.send();
}
function checkRecoveryAndFinish() {
// After any MFA is set up, fetch recovery codes
showRecoveryCodes();
updateFinishBtn();
}
document.getElementById('copy-recovery-btn').addEventListener('click', function () {
var text = document.getElementById('recovery-codes').textContent;
navigator.clipboard.writeText(text).then(function () {
document.getElementById('copy-recovery-btn').textContent = '✅ 已复制';
setTimeout(function () {
document.getElementById('copy-recovery-btn').textContent = '📋 复制';
}, 2000);
});
});
// ─── Finish ────────────────────────────
function updateFinishBtn() {
var btn = document.getElementById('finish-btn');
if (passkeyDone || totpDone) {
btn.disabled = false;
btn.textContent = '✅ 完成设置,进入控制面板';
} else {
btn.disabled = true;
btn.textContent = '⚠️ 请至少配置一种认证方式';
}
}
document.getElementById('finish-btn').addEventListener('click', function () {
var msg = document.getElementById('finish-msg');
msg.textContent = '正在完成设置...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/mfa/complete');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status === 200) {
location.href = '/';
} else {
var res = JSON.parse(xhr.responseText || '{}');
msg.textContent = res.error || '完成失败,请重试';
}
};
xhr.send();
});
// ─── Helpers ────────────────────────────
// Convert base64url (from server/webauthn) to ArrayBuffer
function _base64ToArrayBuffer(b64) {
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
// Convert ArrayBuffer to base64url (for WebAuthn API)
function _arrayBufferToBase64(buf) {
var bytes = new Uint8Array(buf);
var binary = '';
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
})();
</script>
</body>
</html>