2026-05-21 22:11:38 +08:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>Nexus — 登录</title>
|
|
|
|
|
|
|
|
|
|
<!-- Tailwind CSS v4 CDN -->
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
|
|
|
|
|
|
|
|
<style type="text/tailwindcss">
|
|
|
|
|
@theme {
|
|
|
|
|
--color-brand: oklch(55% 0.2 250);
|
|
|
|
|
--color-brand-light: oklch(75% 0.15 250);
|
|
|
|
|
--color-brand-dark: oklch(35% 0.18 250);
|
|
|
|
|
--color-surface: oklch(98.5% 0.002 250);
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body class="bg-slate-950 min-h-screen flex items-center justify-center p-4">
|
|
|
|
|
|
|
|
|
|
<div class="w-full max-w-md">
|
|
|
|
|
|
|
|
|
|
<!-- Logo / Brand -->
|
|
|
|
|
<div class="text-center mb-8">
|
|
|
|
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-brand/20 mb-4">
|
|
|
|
|
<svg class="w-8 h-8 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<h1 class="text-2xl font-bold text-white">Nexus</h1>
|
|
|
|
|
<p class="text-slate-400 text-sm mt-1">服务器运维管理平台</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Login Card -->
|
|
|
|
|
<div id="loginCard" class="bg-slate-900 rounded-2xl border border-slate-800 p-8 shadow-xl">
|
|
|
|
|
|
|
|
|
|
<!-- Step 1: Password -->
|
|
|
|
|
<div id="stepPassword">
|
|
|
|
|
<h2 class="text-lg font-semibold text-white mb-6">登录到您的账户</h2>
|
|
|
|
|
|
|
|
|
|
<form id="loginForm" class="space-y-5">
|
|
|
|
|
<div>
|
|
|
|
|
<label class="block text-sm font-medium text-slate-300 mb-2">用户名</label>
|
|
|
|
|
<input type="text" id="username" name="username" required autocomplete="username"
|
|
|
|
|
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
|
|
|
|
placeholder="admin">
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label class="block text-sm font-medium text-slate-300 mb-2">密码</label>
|
|
|
|
|
<input type="password" id="password" name="password" required autocomplete="current-password"
|
|
|
|
|
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
|
|
|
|
placeholder="••••••••">
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button type="submit" id="loginBtn"
|
|
|
|
|
class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
|
|
|
登录
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Step 2: TOTP (hidden by default) -->
|
|
|
|
|
<div id="stepTotp" class="hidden">
|
|
|
|
|
<h2 class="text-lg font-semibold text-white mb-2">两步验证</h2>
|
|
|
|
|
<p class="text-slate-400 text-sm mb-6">请输入验证器应用中的 6 位数字</p>
|
|
|
|
|
|
|
|
|
|
<form id="totpForm" class="space-y-5">
|
|
|
|
|
<div>
|
|
|
|
|
<input type="text" id="totpCode" name="totp_code" required maxlength="6" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
|
|
|
|
|
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-center text-2xl tracking-[0.5em] placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
|
|
|
|
placeholder="000000">
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button type="submit" id="totpBtn"
|
|
|
|
|
class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
|
|
|
验证
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button type="button" id="backBtn"
|
|
|
|
|
class="w-full py-2 text-slate-400 hover:text-white text-sm transition">
|
|
|
|
|
← 返回
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Error Message -->
|
|
|
|
|
<div id="errorMsg" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center"></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Footer -->
|
|
|
|
|
<p class="text-center text-slate-600 text-xs mt-6">Nexus v6.0 · 安全连接</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
const API = window.location.origin + '/api/auth';
|
|
|
|
|
let pendingUsername = '';
|
|
|
|
|
let pendingPassword = '';
|
|
|
|
|
|
|
|
|
|
// ── Step 1: Password Login ──
|
|
|
|
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
hideError();
|
|
|
|
|
|
|
|
|
|
const username = document.getElementById('username').value.trim();
|
|
|
|
|
const password = document.getElementById('password').value;
|
|
|
|
|
if (!username || !password) return;
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('loginBtn');
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
btn.textContent = '登录中...';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(API + '/login', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ username, password }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (res.status === 202 && data.detail === '需要TOTP验证码') {
|
|
|
|
|
// TOTP required — show step 2
|
|
|
|
|
pendingUsername = username;
|
|
|
|
|
pendingPassword = password;
|
|
|
|
|
showTotpStep();
|
|
|
|
|
} else if (!res.ok) {
|
|
|
|
|
showError(data.detail || '登录失败');
|
|
|
|
|
} else {
|
|
|
|
|
// Success — store tokens and redirect
|
|
|
|
|
onLoginSuccess(data);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
showError('网络错误,请检查连接');
|
|
|
|
|
} finally {
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
btn.textContent = '登录';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Step 2: TOTP Verification ──
|
|
|
|
|
document.getElementById('totpForm').addEventListener('submit', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
hideError();
|
|
|
|
|
|
|
|
|
|
const totpCode = document.getElementById('totpCode').value.trim();
|
|
|
|
|
if (totpCode.length !== 6) return;
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('totpBtn');
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
btn.textContent = '验证中...';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(API + '/login', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
username: pendingUsername,
|
|
|
|
|
password: pendingPassword,
|
|
|
|
|
totp_code: totpCode,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
showError(data.detail || '验证码错误');
|
|
|
|
|
document.getElementById('totpCode').value = '';
|
|
|
|
|
document.getElementById('totpCode').focus();
|
|
|
|
|
} else {
|
|
|
|
|
onLoginSuccess(data);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
showError('网络错误,请检查连接');
|
|
|
|
|
} finally {
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
btn.textContent = '验证';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Back button
|
|
|
|
|
document.getElementById('backBtn').addEventListener('click', () => {
|
|
|
|
|
showPasswordStep();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Auto-focus TOTP input
|
|
|
|
|
function showTotpStep() {
|
|
|
|
|
document.getElementById('stepPassword').classList.add('hidden');
|
|
|
|
|
document.getElementById('stepTotp').classList.remove('hidden');
|
|
|
|
|
document.getElementById('totpCode').value = '';
|
|
|
|
|
document.getElementById('totpCode').focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showPasswordStep() {
|
|
|
|
|
document.getElementById('stepTotp').classList.add('hidden');
|
|
|
|
|
document.getElementById('stepPassword').classList.remove('hidden');
|
|
|
|
|
document.getElementById('password').focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Success Handler ──
|
|
|
|
|
function onLoginSuccess(data) {
|
|
|
|
|
localStorage.setItem('access_token', data.access_token);
|
|
|
|
|
localStorage.setItem('refresh_token', data.refresh_token);
|
|
|
|
|
localStorage.setItem('admin', JSON.stringify(data.admin));
|
2026-05-22 08:19:56 +08:00
|
|
|
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
|
2026-05-21 22:11:38 +08:00
|
|
|
window.location.href = '/app/index.html';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Error Display ──
|
|
|
|
|
function showError(msg) {
|
|
|
|
|
const el = document.getElementById('errorMsg');
|
|
|
|
|
el.textContent = msg;
|
|
|
|
|
el.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideError() {
|
|
|
|
|
document.getElementById('errorMsg').classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Check existing session ──
|
|
|
|
|
(function checkSession() {
|
|
|
|
|
const token = localStorage.getItem('access_token');
|
|
|
|
|
const expires = localStorage.getItem('token_expires');
|
|
|
|
|
if (token && expires && Date.now() < parseInt(expires)) {
|
|
|
|
|
window.location.href = '/app/index.html';
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|