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
+401
View File
@@ -0,0 +1,401 @@
<!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;
}
.login-box {
width: min(92vw, 400px);
background: var(--card-bg);
border-radius: var(--radius);
padding: 32px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
text-align: center;
}
.login-box h1 { font-size: 22px; margin-bottom: 8px; font-weight: 600; }
.login-box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; }
/* Passkey section */
.passkey-section { margin-bottom: 20px; }
.passkey-btn {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 600;
border: 2px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: opacity 0.15s;
}
.passkey-btn:hover { opacity: 0.9; }
.passkey-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.passkey-btn .icon { font-size: 22px; }
.passkey-status { font-size: 13px; color: var(--text-dim); margin-top: 8px; min-height: 20px; }
/* Divider */
.divider {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
color: var(--text-dim);
font-size: 13px;
}
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #e0e0e0; }
/* TOTP form */
.totp-section { display: none; }
.totp-section.visible { display: block; }
.totp-section input[type="text"] {
width: 100%;
padding: 12px;
font-size: 24px;
text-align: center;
letter-spacing: 8px;
border-radius: 8px;
border: 1px solid #ddd;
background: #fafafa;
color: var(--text);
margin-bottom: 12px;
font-family: monospace;
}
.totp-section button {
width: 100%;
padding: 12px;
font-size: 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.totp-section button:hover { opacity: 0.9; }
/* Recovery code section */
.recovery-link {
display: inline-block;
margin-top: 12px;
font-size: 13px;
color: var(--accent);
cursor: pointer;
text-decoration: underline;
}
.recovery-link:hover { opacity: 0.8; }
.recovery-section { display: none; }
.recovery-section.visible { display: block; }
.recovery-section input[type="text"] {
width: 100%;
padding: 12px;
font-size: 18px;
text-align: center;
letter-spacing: 2px;
border-radius: 8px;
border: 1px solid #ddd;
background: #fafafa;
color: var(--text);
margin-bottom: 12px;
font-family: monospace;
}
.recovery-section button {
width: 100%;
padding: 12px;
font-size: 16px;
border: none;
border-radius: 8px;
background: var(--accent-green);
color: #fff;
cursor: pointer;
}
.recovery-section button:hover { opacity: 0.9; }
.msg { min-height: 22px; font-size: 14px; color: var(--accent-red); margin-top: 10px; }
/* ═══ 主题适配(登录页)═══ */
body { background: var(--bg); }
.login-box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
.totp-section input[type="text"], .recovery-section input[type="text"] {
background: var(--input-bg); border-color: var(--input-border); color: var(--text);
}
.divider::before, .divider::after { background: var(--divider); }
</style>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="login-box">
<h1>🔐 控制端登录</h1>
<p class="subtitle" id="subtitle">正在准备登录...</p>
<!-- Passkey -->
<div class="passkey-section" id="passkey-section">
<button class="passkey-btn" id="passkey-btn">
<span class="icon">🔑</span>
<span>使用 Passkey 登录</span>
</button>
<div class="passkey-status" id="passkey-status">点击上方按钮或等待自动调起</div>
</div>
<div class="divider" id="divider">或</div>
<!-- Code input (TOTP or temp password) -->
<div class="totp-section" id="code-section">
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入验证码或临时密码 (6位数字)</p>
<form id="code-form">
<input type="text" id="code-input" placeholder="000000" maxlength="6" inputmode="numeric" pattern="[0-9]*" autocomplete="off" required>
<button type="submit">登录</button>
<div class="msg" id="code-msg"></div>
</form>
<span class="recovery-link" id="show-recovery">使用恢复码</span>
</div>
<!-- Recovery Code -->
<div class="recovery-section" id="recovery-section">
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入一次性恢复码 (格式: XXXX-XXXXXX)</p>
<form id="recovery-form">
<input type="text" id="recovery-input" placeholder="ABCD-EFGHIJ" maxlength="15" autocomplete="off" required>
<button type="submit">验证</button>
<div class="msg" id="recovery-msg"></div>
</form>
<span class="recovery-link" id="show-totp">返回 TOTP</span>
</div>
<div style="margin-top:20px;font-size:13px;">
<a href="/register-request" style="color:var(--accent);text-decoration:none;">还没有账号?申请账号</a>
</div>
</div>
<script>
(function () {
'use strict';
var redirect = new URLSearchParams(location.search).get('next') || '/control';
var view = 'control';
// ─── Redirect ─────────────────────────
function _redirect(url) {
location.href = url;
}
// ─── Check auth status ──────────────────
checkStatus(function (status) {
if (status.authenticated) {
_redirect(redirect);
return;
}
if (status.temp_pending) {
_redirect('/mfa-setup');
return;
}
// Show unified login: Passkey + 6-digit input (accepts TOTP or temp password)
showLoginForm(status.mfa_configured);
});
function checkStatus(callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/status');
xhr.onload = function () {
if (xhr.status === 200) callback(JSON.parse(xhr.responseText));
};
xhr.send();
}
function showLoginForm(mfaConfigured) {
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('divider').style.display = 'flex';
document.getElementById('code-section').classList.add('visible');
if (mfaConfigured) {
document.getElementById('subtitle').textContent = '使用 Passkey 或输入 TOTP 验证码';
} else {
document.getElementById('subtitle').textContent = '首次使用?输入控制台显示的临时密码 (6位)';
}
// Auto-trigger Passkey if available
setTimeout(triggerPasskey, 500);
}
// ─── Unified Login (TOTP / Temp Password) ─
document.getElementById('code-form').addEventListener('submit', function (e) {
e.preventDefault();
var code = document.getElementById('code-input').value;
var msg = document.getElementById('code-msg');
msg.textContent = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/login');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
if (res.method === 'temp_password' || res.must_configure_mfa) {
_redirect('/mfa-setup');
} else {
_redirect(redirect);
}
} else {
msg.textContent = res.error || '验证失败';
document.getElementById('code-input').value = '';
}
};
var payload = { code: code, view: view };
xhr.send(JSON.stringify(payload));
});
// Auto-submit on 6 digits
document.getElementById('code-input').addEventListener('input', function () {
if (this.value.length === 6) {
document.getElementById('code-form').dispatchEvent(new Event('submit'));
}
});
// ─── Recovery Code Login ────────────────
document.getElementById('show-recovery').addEventListener('click', function () {
document.getElementById('code-section').classList.remove('visible');
document.getElementById('recovery-section').classList.add('visible');
document.getElementById('subtitle').textContent = '输入一次性恢复码';
});
document.getElementById('show-totp').addEventListener('click', function () {
document.getElementById('recovery-section').classList.remove('visible');
document.getElementById('code-section').classList.add('visible');
document.getElementById('subtitle').textContent = '输入验证码或临时密码 (6位数字)';
});
document.getElementById('recovery-form').addEventListener('submit', function (e) {
e.preventDefault();
var code = document.getElementById('recovery-input').value;
var msg = document.getElementById('recovery-msg');
msg.textContent = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/login/recovery');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
_redirect(redirect);
} else {
msg.textContent = res.error || '恢复码错误';
document.getElementById('recovery-input').value = '';
}
};
var payload = { code: code, view: view };
xhr.send(JSON.stringify(payload));
});
// ─── Passkey Login ──────────────────────
function triggerPasskey() {
var statusEl = document.getElementById('passkey-status');
var btn = document.getElementById('passkey-btn');
statusEl.textContent = '正在请求安全密钥...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/webauthn/login/begin');
xhr.onload = function () {
if (xhr.status !== 200) {
statusEl.textContent = 'Passkey 不可用,请使用其他方式';
return;
}
var options = JSON.parse(xhr.responseText);
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.assertion(options)
.then(function (credential) {
var verify = new XMLHttpRequest();
verify.open('POST', '/api/auth/webauthn/login/complete');
verify.setRequestHeader('Content-Type', 'application/json');
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
statusEl.textContent = '✅ 登录成功!';
_redirect(redirect);
} else {
statusEl.textContent = '❌ ' + (res.error || '验证失败');
document.getElementById('code-input').focus();
}
};
var p = { credential: credential, view: view };
verify.send(JSON.stringify(p));
})
.catch(function (err) {
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
document.getElementById('code-input').focus();
});
};
xhr.send();
}
document.getElementById('passkey-btn').addEventListener('click', triggerPasskey);
// ─── Helpers ────────────────────────────
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;
}
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>