Files
iot-dboard/frontend/templates/login.html
T
2026-08-06 11:11:11 +08:00

435 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>