399 lines
13 KiB
HTML
399 lines
13 KiB
HTML
<!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>
|