Files
MoonTVPlus/src/app/login/page.tsx
T
2026-06-29 17:11:59 +08:00

530 lines
20 KiB
TypeScript
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.
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { AlertCircle, CheckCircle, Eye, EyeOff, Send, User, Lock } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { CURRENT_VERSION } from '@/lib/version';
import { checkForUpdates, UpdateStatus } from '@/lib/version_check';
import { useSite } from '@/components/SiteProvider';
import { ThemeToggle } from '@/components/ThemeToggle';
// 版本显示组件
function VersionDisplay() {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkUpdate = async () => {
try {
const status = await checkForUpdates();
setUpdateStatus(status);
} catch (_) {
// do nothing
} finally {
setIsChecking(false);
}
};
checkUpdate();
}, []);
return (
<button
onClick={() =>
window.open('https://github.com/mtvpls/MoonTVPlus', '_blank')
}
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
>
<span className='font-mono'>v{CURRENT_VERSION}</span>
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
<div
className={`flex items-center gap-1.5 ${updateStatus === UpdateStatus.HAS_UPDATE
? 'text-yellow-600 dark:text-yellow-400'
: updateStatus === UpdateStatus.NO_UPDATE
? 'text-green-600 dark:text-green-400'
: ''
}`}
>
{updateStatus === UpdateStatus.HAS_UPDATE && (
<>
<AlertCircle className='w-3.5 h-3.5' />
<span className='font-semibold text-xs'>有新版本</span>
</>
)}
{updateStatus === UpdateStatus.NO_UPDATE && (
<>
<CheckCircle className='w-3.5 h-3.5' />
<span className='font-semibold text-xs'>已是最新</span>
</>
)}
</div>
)}
</button>
);
}
// 根据按钮文本识别OIDC提供商并返回对应的图标
function getOIDCProviderIcon(buttonText: string) {
const text = buttonText.toLowerCase();
const providers = [
{ keywords: ['linuxdo'], icon: '/icons/linuxdo.png', alt: 'LinuxDo' },
{ keywords: ['github'], icon: '/icons/github.png', alt: 'GitHub' },
{ keywords: ['google'], icon: '/icons/google.png', alt: 'Google' },
{ keywords: ['microsoft', 'azure', 'entra'], icon: '/icons/microsoft.png', alt: 'Microsoft' },
{ keywords: ['gitlab'], icon: '/icons/gitlab.png', alt: 'GitLab' },
];
for (const provider of providers) {
if (provider.keywords.some(keyword => text.includes(keyword))) {
return <img src={provider.icon} alt={provider.alt} className='w-5 h-5 mr-2' />;
}
}
// 默认图标
return (
<svg className='w-5 h-5 mr-2' fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z' clipRule='evenodd' />
</svg>
);
}
function LoginPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [shouldAskUsername, setShouldAskUsername] = useState(false);
const [rememberPassword, setRememberPassword] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [turnstileLoaded, setTurnstileLoaded] = useState(false);
const [siteConfig, setSiteConfig] = useState<any>(null);
const [turnstileWidgetId, setTurnstileWidgetId] = useState<string | null>(null);
const [backgroundImage, setBackgroundImage] = useState<string>('');
const [telegramLoginEnabled, setTelegramLoginEnabled] = useState(false);
const [telegramLoginLoading, setTelegramLoginLoading] = useState(false);
const [telegramLoginHint, setTelegramLoginHint] = useState<string | null>(null);
const { siteName } = useSite();
// 处理URL中的error参数
useEffect(() => {
const errorParam = searchParams.get('error');
if (errorParam) {
setError(decodeURIComponent(errorParam));
}
}, [searchParams]);
// 在客户端挂载后设置配置
useEffect(() => {
if (typeof window !== 'undefined') {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
const storageType = runtimeConfig?.STORAGE_TYPE;
const shouldAsk = storageType && storageType !== 'localstorage';
setShouldAskUsername(shouldAsk);
// 设置背景图(支持多张随机选择)
const loginBg = runtimeConfig?.LOGIN_BACKGROUND_IMAGE;
if (loginBg) {
const urls = loginBg
.split('\n')
.map((url: string) => url.trim())
.filter((url: string) => url !== '');
if (urls.length > 0) {
// 随机选择一张背景图
const randomIndex = Math.floor(Math.random() * urls.length);
setBackgroundImage(urls[randomIndex]);
}
}
// 设置站点配置
setSiteConfig({
LoginRequireTurnstile: runtimeConfig?.LOGIN_REQUIRE_TURNSTILE || false,
TurnstileSiteKey: runtimeConfig?.TURNSTILE_SITE_KEY || '',
EnableRegistration: runtimeConfig?.ENABLE_REGISTRATION || false,
EnableOIDCLogin: runtimeConfig?.ENABLE_OIDC_LOGIN || false,
OIDCButtonText: runtimeConfig?.OIDC_BUTTON_TEXT || '',
});
setTelegramLoginEnabled(Boolean(runtimeConfig?.ENABLE_TELEGRAM_LOGIN));
// 从localStorage读取记住的密码信息
const rememberedCredentials = localStorage.getItem('rememberedCredentials');
if (rememberedCredentials) {
try {
const credentials = JSON.parse(rememberedCredentials);
if (credentials.password) {
setPassword(credentials.password);
}
if (credentials.username && shouldAsk) {
setUsername(credentials.username);
}
setRememberPassword(true);
} catch (error) {
// 清除无效的数据
localStorage.removeItem('rememberedCredentials');
}
}
}
}, []);
// 加载Cloudflare Turnstile脚本
useEffect(() => {
if (!siteConfig?.LoginRequireTurnstile || !siteConfig?.TurnstileSiteKey) {
return;
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
script.onload = () => {
setTurnstileLoaded(true);
};
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, [siteConfig]);
// 渲染Turnstile组件
useEffect(() => {
if (!turnstileLoaded || !siteConfig?.TurnstileSiteKey) {
return;
}
const container = document.getElementById('turnstile-container');
if (container && (window as any).turnstile) {
const widgetId = (window as any).turnstile.render('#turnstile-container', {
sitekey: siteConfig.TurnstileSiteKey,
callback: (token: string) => {
setTurnstileToken(token);
},
});
setTurnstileWidgetId(widgetId);
}
}, [turnstileLoaded, siteConfig]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
if (!password || (shouldAskUsername && !username)) return;
// 检查Turnstile验证
if (siteConfig?.LoginRequireTurnstile && !turnstileToken) {
setError('请完成人机验证');
return;
}
try {
setLoading(true);
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
password,
...(shouldAskUsername ? { username } : {}),
...(siteConfig?.LoginRequireTurnstile ? { turnstileToken } : {}),
}),
});
if (res.ok) {
// 处理记住密码逻辑
if (rememberPassword) {
const credentials: any = { password };
// 如果需要用户名且有用户名,就保存用户名
if (shouldAskUsername && username) {
credentials.username = username;
}
localStorage.setItem('rememberedCredentials', JSON.stringify(credentials));
} else {
// 如果不记住密码,清除已存储的信息
localStorage.removeItem('rememberedCredentials');
}
const redirect = searchParams.get('redirect') || '/';
window.location.replace(redirect);
} else {
// 登录失败,重置Turnstile
if (siteConfig?.LoginRequireTurnstile && turnstileWidgetId !== null && (window as any).turnstile) {
(window as any).turnstile.reset(turnstileWidgetId);
setTurnstileToken(null);
}
if (res.status === 401) {
setError('密码错误');
} else {
const data = await res.json().catch(() => ({}));
setError(data.error ?? '服务器错误');
}
}
} catch (error) {
// 网络错误,重置Turnstile
if (siteConfig?.LoginRequireTurnstile && turnstileWidgetId !== null && (window as any).turnstile) {
(window as any).turnstile.reset(turnstileWidgetId);
setTurnstileToken(null);
}
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
const handleTelegramLogin = async () => {
setError(null);
setTelegramLoginHint(null);
try {
setTelegramLoginLoading(true);
const createRes = await fetch('/api/telegram/login/create', { method: 'POST' });
const createData = await createRes.json().catch(() => ({}));
if (!createRes.ok) {
const configDetail = createData.config
? `enabled=${String(createData.config.enabled)}, loginEnabled=${String(createData.config.loginEnabled)}, hasBotToken=${String(createData.config.hasBotToken)}, hasBotUsername=${String(createData.config.hasBotUsername)}, botUsername=${createData.config.botUsername || '-'}`
: `HTTP ${createRes.status}`;
setError(`${createData.error || 'Telegram 登录接口不可用'}${configDetail}`);
return;
}
setTelegramLoginHint('请在 Telegram 中确认登录');
window.open(createData.deepLink, '_blank', 'noopener,noreferrer');
const startedAt = Date.now();
const timer = window.setInterval(async () => {
if (Date.now() - startedAt > 5 * 60 * 1000) {
window.clearInterval(timer);
setTelegramLoginLoading(false);
setTelegramLoginHint(null);
setError('Telegram 登录已超时,请重试');
return;
}
const statusRes = await fetch(`/api/telegram/login/status?token=${encodeURIComponent(createData.token)}`);
const statusData = await statusRes.json().catch(() => ({}));
if (statusData.status === 'confirmed') {
window.clearInterval(timer);
const redirect = searchParams.get('redirect') || '/';
window.location.replace(redirect);
} else if (statusData.status === 'denied') {
window.clearInterval(timer);
setTelegramLoginLoading(false);
setTelegramLoginHint(null);
setError('已拒绝 Telegram 登录');
} else if (statusData.status === 'expired') {
window.clearInterval(timer);
setTelegramLoginLoading(false);
setTelegramLoginHint(null);
setError('Telegram 登录已过期');
}
}, 2000);
} catch (error) {
setError('Telegram 登录请求失败,请稍后重试');
setTelegramLoginLoading(false);
setTelegramLoginHint(null);
}
};
return (
<div
className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'
style={backgroundImage ? {
backgroundImage: `url(${backgroundImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
} : undefined}
>
<div className='absolute top-4 right-4'>
<ThemeToggle />
</div>
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 shadow-2xl p-10 dark:border dark:border-zinc-800'>
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
{siteName}
</h1>
<form onSubmit={handleSubmit} className='space-y-8'>
{shouldAskUsername && (
<div>
<label htmlFor='username' className='sr-only'>
用户名
</label>
<div className='relative'>
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
<User className='h-5 w-5 text-gray-400 dark:text-gray-500' />
</div>
<input
id='username'
type='text'
autoComplete='username'
className='block w-full rounded-lg border-0 py-3 pl-10 pr-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
placeholder='输入用户名'
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
)}
<div>
<label htmlFor='password' className='sr-only'>
密码
</label>
<div className='relative'>
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
<Lock className='h-5 w-5 text-gray-400 dark:text-gray-500' />
</div>
<input
id='password'
type={showPassword ? 'text' : 'password'}
autoComplete='current-password'
className='block w-full rounded-lg border-0 py-3 pl-10 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
placeholder='输入访问密码'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type='button'
className='absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className='h-5 w-5' />
) : (
<Eye className='h-5 w-5' />
)}
</button>
</div>
</div>
{/* Cloudflare Turnstile */}
{siteConfig?.LoginRequireTurnstile && siteConfig?.TurnstileSiteKey && (
<div id='turnstile-container' className='flex justify-center'></div>
)}
{error && (
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
)}
{/* 记住密码复选框 */}
<div className='flex items-center'>
<input
id='remember-password'
type='checkbox'
className='h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700'
checked={rememberPassword}
onChange={(e) => setRememberPassword(e.target.checked)}
/>
<label
htmlFor='remember-password'
className='ml-2 block text-sm text-gray-700 dark:text-gray-300'
>
记住密码
</label>
</div>
{/* 登录按钮 */}
<button
type='submit'
disabled={
!password || loading || (shouldAskUsername && !username) ||
(siteConfig?.LoginRequireTurnstile && !turnstileToken)
}
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
>
{loading ? '登录中...' : '登录'}
</button>
{/* 注册按钮 */}
{siteConfig?.EnableRegistration && shouldAskUsername && (
<div className='text-center'>
<button
type='button'
onClick={() => router.push('/register')}
className='text-sm text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 transition-colors'
>
还没有账号?立即注册
</button>
</div>
)}
</form>
{/* Telegram登录按钮 */}
{telegramLoginEnabled && shouldAskUsername && (
<div className='mt-6'>
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
</div>
<div className='relative flex justify-center text-sm'>
<span className='px-2 bg-white/60 dark:bg-zinc-900/60 text-gray-500 dark:text-gray-400'>
</span>
</div>
</div>
<button
type='button'
disabled={telegramLoginLoading}
onClick={handleTelegramLogin}
className='mt-4 w-full inline-flex justify-center items-center rounded-lg border-2 border-sky-300 dark:border-sky-700 bg-white/60 dark:bg-zinc-800/60 py-3 text-base font-semibold text-sky-700 dark:text-sky-300 shadow-sm transition-all duration-200 hover:bg-sky-50 dark:hover:bg-sky-900/30 disabled:cursor-not-allowed disabled:opacity-60'
>
<Send className='w-5 h-5 mr-2' />
{telegramLoginLoading ? '等待 Telegram 确认...' : '使用 Telegram 登录'}
</button>
{telegramLoginHint && (
<p className='mt-2 text-center text-xs text-gray-500 dark:text-gray-400'>
{telegramLoginHint}
</p>
)}
</div>
)}
{/* OIDC登录按钮 */}
{siteConfig?.EnableOIDCLogin && shouldAskUsername && (
<div className='mt-6'>
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
</div>
<div className='relative flex justify-center text-sm'>
<span className='px-2 bg-white/60 dark:bg-zinc-900/60 text-gray-500 dark:text-gray-400'>
</span>
</div>
</div>
<button
type='button'
onClick={() => window.location.href = '/api/auth/oidc/login'}
className='mt-4 w-full inline-flex justify-center items-center rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-white/60 dark:bg-zinc-800/60 py-3 text-base font-semibold text-gray-700 dark:text-gray-200 shadow-sm transition-all duration-200 hover:bg-gray-50 dark:hover:bg-zinc-700/60'
>
{getOIDCProviderIcon(siteConfig?.OIDCButtonText || '')}
{siteConfig?.OIDCButtonText || '使用OIDC登录'}
</button>
</div>
)}
</div>
{/* 版本信息显示 */}
<VersionDisplay />
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LoginPageClient />
</Suspense>
);
}