'use client'; import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useSearchParams } from 'next/navigation'; import { EyeIcon, EyeOffIcon } from 'lucide-react'; import { toast } from 'sonner'; import Link from 'next/link'; import { useAuth } from '@/components/providers/auth-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { Spinner } from '@/components/ui/spinner'; import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'; import { CapWidget } from '@/components/auth/cap-widget'; import { AuthHeading } from '@/components/auth/auth-shell'; import { OTPForm } from './otp-form'; import { AuthService } from '@/lib/services/auth'; import { ConfigService } from '@/lib/services/config'; import type { LoginRequest } from '@/lib/services/auth/types'; import { safeRedirectTarget } from '@/lib/utils'; function persistRedirectTarget( searchParams: ReturnType, ) { const callbackUrl = searchParams.get('callbackUrl'); if (callbackUrl && typeof window !== 'undefined') { sessionStorage.setItem( 'redirect_after_login', safeRedirectTarget(callbackUrl), ); } } function configBool(value: string | undefined, fallback: boolean) { if (value === undefined) return fallback; return value === 'true'; } export function LoginForm({ onOTPStateChange, }: { onOTPStateChange?: (show: boolean) => void; }) { const searchParams = useSearchParams(); const { setUser } = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [code, setCode] = useState(''); const [showLoginCodeInput, setShowLoginCodeInput] = useState(false); const [loginCooldown, setLoginCooldown] = useState(0); const [errorMessage, setErrorMessage] = useState(''); const [loginCodeTip, setLoginCodeTip] = useState(null); useEffect(() => { onOTPStateChange?.(showLoginCodeInput); }, [showLoginCodeInput, onOTPStateChange]); useEffect(() => { if (loginCooldown > 0) { const timer = setTimeout(() => setLoginCooldown(loginCooldown - 1), 1000); return () => clearTimeout(timer); } }, [loginCooldown]); // Cap token management — ref to hold latest token without triggering re-render const capTokenRef = useRef(null); const [capReady, setCapReady] = useState(false); const [capError, setCapError] = useState(false); const [capResetKey, setCapResetKey] = useState(0); const publicConfigQuery = useQuery({ queryKey: ['public-config'], queryFn: () => ConfigService.getPublicConfig(), }); const authSourcesQuery = useQuery({ queryKey: ['auth-sources'], queryFn: () => AuthService.getAuthSources(), }); const capEnabled = configBool( publicConfigQuery.data?.cap_login_enabled, false, ); const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true); const loginMutation = useMutation({ mutationFn: (req: LoginRequest) => { const headers: Record = {}; if (capEnabled && capTokenRef.current) { headers['X-Cap-Token'] = capTokenRef.current; // Consume the token — next login attempt will need a new one capTokenRef.current = null; setCapReady(false); } return AuthService.login( req, Object.keys(headers).length ? headers : undefined, ); }, onSuccess: (user) => { setUser(user); toast.success('登录成功'); }, onError: (error: Error) => { const errorMsg = error.message || ''; if (errorMsg.startsWith('need_email_code:')) { const emailMasked = errorMsg.substring('need_email_code:'.length); setLoginCodeTip( <> 已向您的安全邮箱{' '} {emailMasked}{' '} 发送了登录验证码。 , ); setShowLoginCodeInput(true); setLoginCooldown(60); toast.success('登录验证码已发送至您的邮箱,请注意查收'); if (capEnabled) { capTokenRef.current = null; setCapReady(false); setCapResetKey((key) => key + 1); } return; } if (errorMsg.startsWith('smtp_invalid:')) { const tip = errorMsg.substring('smtp_invalid:'.length); setLoginCodeTip('请输入您的登录验证码。'); setShowLoginCodeInput(true); setLoginCooldown(0); toast.warning(tip); if (capEnabled) { capTokenRef.current = null; setCapReady(false); setCapResetKey((key) => key + 1); } return; } setErrorMessage(errorMsg || '登录失败,请重试'); if (capEnabled) { capTokenRef.current = null; setCapReady(false); setCapResetKey((key) => key + 1); } }, }); const handlePasswordLogin = () => { setErrorMessage(''); const trimmedUsername = username.trim(); if (!trimmedUsername || !password) { toast.error('邮箱/用户名或密码未填写完整', { description: '请先输入邮箱/用户名和密码后再登录', }); return; } if (capEnabled && !capReady) { toast.error( capAutoSolve ? '人机验证尚未完成,请稍候…' : '请先点击「开始验证」完成人机验证', ); return; } loginMutation.mutate({ username: trimmedUsername, password, code: showLoginCodeInput ? code.trim() : undefined, }); }; const handleResendLoginCode = () => { setCode(''); loginMutation.mutate({ username: username.trim(), password, }); }; const handleOAuthLogin = async (sourceName: string) => { try { setErrorMessage(''); persistRedirectTarget(searchParams); const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName); window.location.href = authorize_url; } catch (error) { toast.error(error instanceof Error ? error.message : '第三方登录失败'); } }; const handleCapToken = (token: string) => { capTokenRef.current = token; setCapReady(true); setCapError(false); }; const handleCapError = () => { capTokenRef.current = null; setCapReady(false); setCapError(true); }; const registrationEnabled = configBool(publicConfigQuery.data?.registration_enabled, true) && configBool(publicConfigQuery.data?.password_register_enabled, true); const passwordLoginEnabled = configBool( publicConfigQuery.data?.password_login_enabled, true, ); const oidcLoginEnabled = configBool( publicConfigQuery.data?.oidc_login_enabled, true, ); const authSources = oidcLoginEnabled ? (authSourcesQuery.data ?? []) : []; const loginDisabled = !passwordLoginEnabled || loginMutation.isPending || (capEnabled && capAutoSolve && !capReady && !capError); if (publicConfigQuery.isPending) { return (
); } if (showLoginCodeInput) { return ( ); } return (
邮箱或用户名 * setUsername(e.target.value)} placeholder='输入邮箱或用户名' autoComplete='username' className='h-10 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handlePasswordLogin()} /> 密码 *
setPassword(e.target.value)} type={showPassword ? 'text' : 'password'} placeholder='输入您的密码' autoComplete='current-password' className='h-10 pr-11 text-sm [@media(max-height:700px)]:h-9' onKeyDown={(e) => e.key === 'Enter' && handlePasswordLogin()} />
{/* Cap 人机验证 */} {capEnabled && ( )} {errorMessage ? (
{errorMessage}
) : null}
{authSources.length > 0 ? (
或者使用
{authSources.map((source) => ( ))}
) : null} {registrationEnabled && (
还没有账号?{' '} 立即注册
)}
); }