Files
iot-dboard/frontend/templates/login_control.html
T
2026-08-11 22:07:55 +08:00

399 lines
13 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">
<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/token.js"></script>
<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>
<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) {
Auth.fetchWithAuth('/api/auth/status')
.then(function (r) { return r.json(); })
.then(function (data) { callback(data); })
.catch(function () { callback({}); });
}
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.access_token) Auth.setAccess(res.access_token);
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) {
if (res.access_token) Auth.setAccess(res.access_token);
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>