411 lines
14 KiB
HTML
411 lines
14 KiB
HTML
<!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/token.js"></script>
|
||
<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 ──────────────────
|
||
// 携带本地 access token 检查(token 有效则直接放行,避免重复登录)
|
||
Auth.fetchWithAuth('/api/auth/status')
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (status) {
|
||
if (status.authenticated) {
|
||
_redirect(redirect);
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
|
||
// ─── 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.access_token) Auth.setAccess(res.access_token);
|
||
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) {
|
||
if (res.access_token) Auth.setAccess(res.access_token);
|
||
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) {
|
||
if (res.access_token) Auth.setAccess(res.access_token);
|
||
_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();
|
||
}
|
||
});
|
||
|
||
// ─── 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>
|