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
+434
View File
@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<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;
}
/* 浅色主题覆盖 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: 12px;
}
/* 主题切换动画 */
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 {
width: 100vw; height: 100vh; height: 100dvh;
overflow: hidden;
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;
user-select: none; -webkit-user-select: none; touch-action: manipulation;
}
.login-box {
width: min(92vw, 420px);
background: var(--card-bg);
border-radius: var(--radius);
padding: clamp(20px, 4vh, 36px) clamp(20px, 4vw, 32px);
text-align: center;
}
.login-box h1 { font-size: clamp(18px, 3vw, 26px); margin-bottom: 4px; font-weight: 600; }
.subtitle { font-size: clamp(12px, 1.8vw, 16px); color: var(--text-dim); margin-bottom: clamp(16px, 3vh, 24px); }
/* TOTP dots */
.totp-dots {
display: flex; justify-content: center; gap: clamp(8px, 1.5vw, 14px);
margin-bottom: clamp(16px, 3vh, 28px);
}
.totp-dot {
width: clamp(14px, 2.5vw, 18px); height: clamp(14px, 2.5vw, 18px);
border-radius: 50%;
border: 2px solid var(--text-dim);
background: transparent;
transition: background 0.15s, border-color 0.15s;
}
.totp-dot.filled { background: var(--accent); border-color: var(--accent); }
.totp-dot.error { background: var(--accent-red); border-color: var(--accent-red); }
/* Numpad */
.numpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(10px, 2vw, 16px);
margin-bottom: 16px;
}
.numpad button {
font-family: inherit;
font-size: clamp(22px, 4vw, 32px);
font-weight: 500;
padding: clamp(12px, 2.5vh, 20px) 0;
border-radius: var(--radius);
border: none;
background: rgba(255,255,255,0.06);
color: var(--text);
cursor: pointer;
transition: background 0.1s;
}
.numpad button:active { background: rgba(255,255,255,0.14); }
.numpad .btn-empty { background: transparent; pointer-events: none; }
.numpad .btn-back { font-size: clamp(16px, 3vw, 24px); color: var(--text-dim); }
/* Alternative methods */
.alt-methods {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.alt-btn {
width: 100%;
padding: clamp(10px, 1.5vh, 14px);
font-size: clamp(13px, 1.8vw, 16px);
font-weight: 500;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
background: rgba(255,255,255,0.04);
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
transition: background 0.1s, color 0.1s;
}
.alt-btn:active { background: rgba(255,255,255,0.10); color: var(--text); }
.alt-btn .icon { margin-right: 6px; }
/* Recovery code input mode */
.recovery-mode { display: none; }
.recovery-mode.visible { display: block; }
.recovery-mode input[type="text"] {
width: 100%;
padding: 14px;
font-size: 20px;
text-align: center;
letter-spacing: 2px;
border-radius: 8px;
border: 1px solid var(--text-dim);
background: rgba(255,255,255,0.04);
color: var(--text);
margin-bottom: 12px;
font-family: monospace;
}
.recovery-mode button {
width: 100%;
padding: 12px;
font-size: 16px;
border: none;
border-radius: 8px;
background: var(--accent-green);
color: #fff;
cursor: pointer;
font-family: inherit;
}
.msg { min-height: 22px; font-size: 14px; color: var(--accent-red); margin-top: 10px; }
.back-link {
display: inline-block;
margin-top: 12px;
font-size: 13px;
color: var(--accent);
cursor: pointer;
}
/* ═══ 浅色主题覆盖(登录页半透明白元素)═══ */
.theme-light .numpad button { background: #e4e9f0; }
.theme-light .numpad button:active { background: #d7dfe8; }
.theme-light .alt-btn { background: #f2f5fa; border-color: #d7dfe8; }
.theme-light .alt-btn:active { background: #e4e9f0; }
.theme-light .recovery-mode input[type="text"] { background: #fafafa; border-color: #d7dfe8; }
</style>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="login-box" id="login-box">
<h1>请输入验证码</h1>
<p class="subtitle" id="subtitle">输入 6 位验证码或临时密码</p>
<!-- TOTP dots -->
<div class="totp-dots" id="totp-dots">
<div class="totp-dot" data-i="0"></div>
<div class="totp-dot" data-i="1"></div>
<div class="totp-dot" data-i="2"></div>
<div class="totp-dot" data-i="3"></div>
<div class="totp-dot" data-i="4"></div>
<div class="totp-dot" data-i="5"></div>
</div>
<!-- TOTP Numpad -->
<div class="numpad" id="numpad">
<button data-key="1">1</button>
<button data-key="2">2</button>
<button data-key="3">3</button>
<button data-key="4">4</button>
<button data-key="5">5</button>
<button data-key="6">6</button>
<button data-key="7">7</button>
<button data-key="8">8</button>
<button data-key="9">9</button>
<button class="btn-empty"></button>
<button data-key="0">0</button>
<button class="btn-back" data-key="back">⌫</button>
</div>
<!-- Alternative methods -->
<div class="alt-methods" id="alt-methods">
<button class="alt-btn" id="passkey-alt-btn"><span class="icon">🔑</span>使用 Passkey 登录</button>
<button class="alt-btn" id="recovery-alt-btn"><span class="icon">🔐</span>使用恢复码</button>
</div>
<div class="msg" id="msg"></div>
<!-- Recovery code input -->
<div class="recovery-mode" id="recovery-mode">
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入一次性恢复码</p>
<input type="text" id="recovery-input" placeholder="ABCD-EFGHIJ" maxlength="15" autocomplete="off">
<button id="recovery-submit">验证恢复码</button>
<div class="back-link" id="back-to-totp">← 返回 TOTP</div>
</div>
<div style="margin-top:16px;font-size:12px;color:var(--text-dim);">
<a href="/register-request" style="color:var(--text-dim);">还没有账号?申请账号</a>
</div>
</div>
<script>
(function () {
'use strict';
var code = '';
var dots = document.querySelectorAll('.totp-dot');
var msgEl = document.getElementById('msg');
var redirect = new URLSearchParams(location.search).get('next') || '/';
var view = 'display';
// ─── Redirect ─────────────────────────
function _redirect(url) {
location.href = url;
}
// ─── Auth status check ──────────────────
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/status');
xhr.onload = function () {
if (xhr.status === 200) {
var status = JSON.parse(xhr.responseText);
if (status.authenticated) {
_redirect(redirect);
}
}
};
xhr.send();
// ─── TOTP dots ──────────────────────────
function updateDots(ok) {
dots.forEach(function (d, i) {
d.classList.toggle('filled', i < code.length);
d.classList.toggle('error', ok === false);
});
if (ok === false) {
setTimeout(function () {
dots.forEach(function (d) { d.classList.remove('error'); });
}, 600);
}
}
function submitCode() {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/login');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status === 200) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.method === 'temp_password' || res.must_configure_mfa) {
_redirect('/mfa-setup');
} else {
_redirect(redirect);
}
} else {
code = '';
updateDots(false);
msgEl.textContent = '验证码错误,请重试';
}
};
xhr.onerror = function () { msgEl.textContent = '网络错误'; };
var payload = { code: code, view: view };
xhr.send(JSON.stringify(payload));
}
document.getElementById('numpad').addEventListener('click', function (e) {
var key = e.target.dataset.key;
if (!key) return;
msgEl.textContent = '';
if (key === 'back') {
code = code.slice(0, -1);
updateDots();
return;
}
if (code.length < 6) {
code += key;
updateDots();
if (code.length === 6) {
submitCode();
}
}
});
// ─── Passkey ────────────────────────────
function triggerPasskey() {
msgEl.textContent = '正在请求安全密钥...';
var btn = document.getElementById('passkey-alt-btn');
btn.disabled = true;
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/webauthn/login/begin');
xhr.onload = function () {
if (xhr.status !== 200) {
msgEl.textContent = 'Passkey 不可用';
btn.disabled = false;
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) {
msgEl.textContent = '✅ 登录成功!';
setTimeout(function () { _redirect(redirect); }, 300);
} else {
msgEl.textContent = '❌ ' + (res.error || '验证失败');
btn.disabled = false;
}
};
var p = { credential: credential, view: view };
verify.send(JSON.stringify(p));
})
.catch(function (err) {
msgEl.textContent = '⏸️ ' + (err.message || '操作取消');
btn.disabled = false;
});
};
xhr.send();
}
// Auto-trigger Passkey on page load (with a small delay)
setTimeout(triggerPasskey, 800);
// ─── Passkey button (manual trigger) ──────
document.getElementById('passkey-alt-btn').addEventListener('click', triggerPasskey);
// ─── Recovery code ──────────────────────
document.getElementById('recovery-alt-btn').addEventListener('click', function () {
document.getElementById('totp-dots').style.display = 'none';
document.getElementById('numpad').style.display = 'none';
document.getElementById('alt-methods').style.display = 'none';
document.getElementById('recovery-mode').classList.add('visible');
document.getElementById('subtitle').textContent = '输入一次性恢复码';
});
document.getElementById('back-to-totp').addEventListener('click', function () {
document.getElementById('totp-dots').style.display = '';
document.getElementById('numpad').style.display = '';
document.getElementById('alt-methods').style.display = '';
document.getElementById('recovery-mode').classList.remove('visible');
document.getElementById('subtitle').textContent = 'TOTP 6 位验证码';
document.getElementById('recovery-input').value = '';
});
document.getElementById('recovery-submit').addEventListener('click', function () {
var code = document.getElementById('recovery-input').value;
msgEl.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 {
msgEl.textContent = res.error || '恢复码错误';
}
};
var payload = { code: code, view: view };
xhr.send(JSON.stringify(payload));
});
document.getElementById('recovery-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
document.getElementById('recovery-submit').click();
}
});
// ─── SSE background listener ────────────
(function () {
function connectSSE() {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
location.href = '/';
} else if (msg.type === 'kick_all') {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () { location.reload(); };
xhr.send();
}
} catch (_) {}
};
es.onerror = function () {
es.close();
setTimeout(connectSSE, 5000);
};
}
connectSSE();
})();
// ─── 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>