Files
Nexus/web/app/login.html
T

233 lines
8.7 KiB
HTML
Raw Normal View History

<!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));
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>