First Push
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user