diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx new file mode 100644 index 00000000..91ab280b --- /dev/null +++ b/frontend/app/(auth)/register/page.tsx @@ -0,0 +1,10 @@ +import {Suspense} from "react" +import {RegisterPage} from "@/components/auth/register-page" + +export default function Page() { + return ( + + + + ) +} diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index f62dde42..031ff983 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -3,19 +3,19 @@ import {useEffect, useMemo, useRef, useState} from "react" import {useMutation, useQuery} from "@tanstack/react-query" import {useRouter, useSearchParams} from "next/navigation" -import {KeyRound, UserPlus} from "lucide-react" +import {KeyRound} 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 {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" import {Card, CardContent} from "@/components/ui/card" import {CapWidget} from "@/components/auth/cap-widget" import services from "@/lib/services" -import type {LoginRequest, RegisterRequest} from "@/lib/services/auth/types" +import type {LoginRequest} from "@/lib/services/auth/types" function getRedirectTarget(searchParams: ReturnType) { const callbackUrl = searchParams.get("callbackUrl") @@ -45,16 +45,12 @@ export function LoginForm() { const router = useRouter() const searchParams = useSearchParams() const { setUser } = useAuth() - const [mode, setMode] = useState<"login" | "register">("login") const [username, setUsername] = useState("") const [password, setPassword] = useState("") - const [nickname, setNickname] = useState("") - const [email, setEmail] = useState("") const [code, setCode] = useState("") const [showLoginCodeInput, setShowLoginCodeInput] = useState(false) const [maskedEmail, setMaskedEmail] = useState("") const [loginCooldown, setLoginCooldown] = useState(0) - const [registerCooldown, setRegisterCooldown] = useState(0) const [errorMessage, setErrorMessage] = useState("") useEffect(() => { @@ -64,13 +60,6 @@ export function LoginForm() { } }, [loginCooldown]) - useEffect(() => { - if (registerCooldown > 0) { - const timer = setTimeout(() => setRegisterCooldown(registerCooldown - 1), 1000) - return () => clearTimeout(timer) - } - }, [registerCooldown]) - // Cap token management — ref to hold latest token without triggering re-render const capTokenRef = useRef(null) const [capReady, setCapReady] = useState(false) @@ -110,6 +99,7 @@ export function LoginForm() { onSuccess: (user) => { setUser(user) router.replace(redirectTarget) + toast.success("登录成功") }, onError: (error: Error) => { const errorMsg = error.message || "" @@ -127,7 +117,7 @@ export function LoginForm() { return } - toast.error(errorMsg || "登录失败,请重试") + setErrorMessage(errorMsg || "登录失败,请重试") if (capEnabled) { capTokenRef.current = null setCapReady(false) @@ -136,48 +126,12 @@ export function LoginForm() { }, }) - const registerMutation = useMutation({ - mutationFn: (req: RegisterRequest) => services.auth.register(req), - onSuccess: (user) => { - setUser(user) - router.replace(redirectTarget) - }, - onError: (error: Error) => { - setErrorMessage(error.message || "注册失败,请重试") - }, - }) - - const sendRegisterCodeMutation = useMutation({ - mutationFn: (targetEmail: string) => services.auth.sendEmailCode(targetEmail, "register"), - onSuccess: () => { - setRegisterCooldown(60) - toast.success("验证码已发送至您的邮箱,请查收") - }, - onError: (error: Error) => { - toast.error(error.message || "发送验证码失败,请重试") - }, - }) - - const handleSendRegisterCode = () => { - const trimmedEmail = email.trim() - if (!trimmedEmail) { - toast.error("请先输入邮箱地址") - return - } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(trimmedEmail)) { - toast.error("请输入有效的邮箱地址") - return - } - sendRegisterCodeMutation.mutate(trimmedEmail) - } - const handlePasswordLogin = () => { setErrorMessage("") const trimmedUsername = username.trim() if (!trimmedUsername || !password) { toast.error("账号或密码未填写完整", { - description: "请先输入账号和密码后再登录", + description: "请先输入账号 and 密码后再登录", }) return } @@ -204,23 +158,6 @@ export function LoginForm() { }) } - const handleRegister = () => { - setErrorMessage("") - if (emailRegisterEnabled) { - if (!email.trim() || !code.trim()) { - toast.error("邮箱和验证码不能为空") - return - } - } - registerMutation.mutate({ - username: username.trim(), - password, - nickname: nickname.trim() || undefined, - email: email.trim() || undefined, - code: code.trim() || undefined, - }) - } - const handleOAuthLogin = async (sourceName: string) => { try { setErrorMessage("") @@ -249,20 +186,23 @@ export function LoginForm() { configBool(publicConfigQuery.data?.password_register_enabled, true) const passwordLoginEnabled = configBool(publicConfigQuery.data?.password_login_enabled, true) - const emailRegisterEnabled = configBool(publicConfigQuery.data?.email_register_verification_enabled, false) - const authSources = authSourcesQuery.data ?? [] - // Login button disabled when: - // - password login is off, OR - // - login mutation is pending, OR - // - cap is enabled AND auto-solve mode AND not yet solved (and not in error state) - // When autoStart=false (manual mode), idle means the user hasn't clicked yet — don't block. const loginDisabled = !passwordLoginEnabled || loginMutation.isPending || (capEnabled && capAutoSolve && !capReady && !capError) + if (publicConfigQuery.isPending) { + return ( + + + + + + ) + } + return ( @@ -272,122 +212,30 @@ export function LoginForm() { - setMode(value as "login" | "register")}> - - 登录 - - 注册 - - + + + + setUsername(e.target.value)} + placeholder="用户名" + autoComplete="username" + onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()} + /> + setPassword(e.target.value)} + type="password" + placeholder="密码" + autoComplete="current-password" + onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()} + /> - - - - setUsername(e.target.value)} - placeholder="用户名" - autoComplete="username" - onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()} - /> - setPassword(e.target.value)} - type="password" - placeholder="密码" - autoComplete="current-password" - onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()} - /> - - {showLoginCodeInput && ( - - - 已向您的安全邮箱 {maskedEmail} 发送了登录验证码。 - - - setCode(e.target.value)} - placeholder="6 位邮箱验证码" - maxLength={6} - className="flex-1" - onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()} - /> - 0 || loginMutation.isPending} - className="w-[120px] text-xs" - > - {loginCooldown > 0 ? `${loginCooldown}秒后重发` : "重新发送"} - - - - )} - - - {/* Cap 人机验证 */} - {capEnabled && ( - - )} - - - {loginMutation.isPending ? ( - <> - - 登录中... - > - ) : ( - <> - - 登录 - > - )} - - - - - - - - setUsername(e.target.value)} - placeholder="用户名" - autoComplete="username" - /> - setNickname(e.target.value)} - placeholder="昵称(可选)" - autoComplete="nickname" - /> - setPassword(e.target.value)} - type="password" - placeholder="密码(至少 8 位)" - autoComplete="new-password" - /> - setEmail(e.target.value)} - placeholder={emailRegisterEnabled ? "电子邮箱" : "电子邮箱(可选)"} - autoComplete="email" - /> - {emailRegisterEnabled && ( + {showLoginCodeInput && ( + + + 已向您的安全邮箱 {maskedEmail} 发送了登录验证码。 + e.key === "Enter" && handlePasswordLogin()} /> 0 || sendRegisterCodeMutation.isPending} + onClick={handleResendLoginCode} + disabled={loginCooldown > 0 || loginMutation.isPending} className="w-[120px] text-xs" > - {registerCooldown > 0 ? `${registerCooldown}秒后重发` : "获取验证码"} + {loginCooldown > 0 ? `${loginCooldown}秒后重发` : "重新发送"} - )} - - - {errorMessage ? ( - - {errorMessage} - ) : null} - - - {registerMutation.isPending ? ( - <> - - 注册中... - > - ) : ( - <> - - 创建账号 - > - )} - + )} - - - + {/* Cap 人机验证 */} + {capEnabled && ( + + )} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {loginMutation.isPending ? ( + <> + + 登录中... + > + ) : ( + <> + + 登录 + > + )} + + + {authSources.length > 0 ? ( - authSources.map((source) => ( - + <> + + - void handleOAuthLogin(source.name)} - > - {source.display_name || source.name} 登录 - + {authSources.map((source) => ( + void handleOAuthLogin(source.name)} + > + {source.display_name || source.name} 登录 + + ))} - )) + > ) : null} + {registrationEnabled && ( + + Don't have an account?{" "} + + Sign up + + + )} ) diff --git a/frontend/components/auth/register-form.tsx b/frontend/components/auth/register-form.tsx new file mode 100644 index 00000000..87d84daa --- /dev/null +++ b/frontend/components/auth/register-form.tsx @@ -0,0 +1,247 @@ +"use client" + +import {useEffect, useMemo, useState} from "react" +import {useMutation, useQuery} from "@tanstack/react-query" +import {useRouter, useSearchParams} from "next/navigation" +import {UserPlus} 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 {Spinner} from "@/components/ui/spinner" +import {Card, CardContent} from "@/components/ui/card" +import services from "@/lib/services" +import type {RegisterRequest} from "@/lib/services/auth/types" + +function getRedirectTarget(searchParams: ReturnType) { + const callbackUrl = searchParams.get("callbackUrl") + const storedRedirect = sessionStorage.getItem("redirect_after_login") + return callbackUrl || storedRedirect || "/home" +} + +function configBool(value: string | undefined, fallback: boolean) { + if (value === undefined) return fallback + return value === "true" +} + +export function RegisterForm() { + const router = useRouter() + const searchParams = useSearchParams() + const { setUser } = useAuth() + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [nickname, setNickname] = useState("") + const [email, setEmail] = useState("") + const [code, setCode] = useState("") + const [registerCooldown, setRegisterCooldown] = useState(0) + const [errorMessage, setErrorMessage] = useState("") + + useEffect(() => { + if (registerCooldown > 0) { + const timer = setTimeout(() => setRegisterCooldown(registerCooldown - 1), 1000) + return () => clearTimeout(timer) + } + }, [registerCooldown]) + + const publicConfigQuery = useQuery({ + queryKey: ["public-config"], + queryFn: () => services.config.getPublicConfig(), + }) + + const redirectTarget = useMemo( + () => getRedirectTarget(searchParams), + [searchParams], + ) + + const registrationEnabled = + configBool(publicConfigQuery.data?.registration_enabled, true) && + configBool(publicConfigQuery.data?.password_register_enabled, true) + + const emailRegisterEnabled = configBool(publicConfigQuery.data?.email_register_verification_enabled, false) + + // Redirect to login if registration is closed + useEffect(() => { + if (publicConfigQuery.isSuccess && !registrationEnabled) { + toast.error("系统注册功能已关闭") + router.replace("/login") + } + }, [publicConfigQuery.isSuccess, registrationEnabled, router]) + + const registerMutation = useMutation({ + mutationFn: (req: RegisterRequest) => services.auth.register(req), + onSuccess: (user) => { + setUser(user) + router.replace(redirectTarget) + toast.success("注册并登录成功") + }, + onError: (error: Error) => { + setErrorMessage(error.message || "注册失败,请重试") + }, + }) + + const sendRegisterCodeMutation = useMutation({ + mutationFn: (targetEmail: string) => services.auth.sendEmailCode(targetEmail, "register"), + onSuccess: () => { + setRegisterCooldown(60) + toast.success("验证码已发送至您的邮箱,请查收") + }, + onError: (error: Error) => { + toast.error(error.message || "发送验证码失败,请重试") + }, + }) + + const handleSendRegisterCode = () => { + const trimmedEmail = email.trim() + if (!trimmedEmail) { + toast.error("请先输入邮箱地址") + return + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(trimmedEmail)) { + toast.error("请输入有效的邮箱地址") + return + } + sendRegisterCodeMutation.mutate(trimmedEmail) + } + + const handleRegister = () => { + setErrorMessage("") + if (!username.trim() || !password) { + toast.error("用户名和密码不能为空") + return + } + if (password.length < 8) { + toast.error("密码长度不能少于 8 位") + return + } + if (emailRegisterEnabled) { + if (!email.trim() || !code.trim()) { + toast.error("邮箱和验证码不能为空") + return + } + } + registerMutation.mutate({ + username: username.trim(), + password, + nickname: nickname.trim() || undefined, + email: email.trim() || undefined, + code: code.trim() || undefined, + }) + } + + if (publicConfigQuery.isPending) { + return ( + + + + + + ) + } + + if (!registrationEnabled) { + return null + } + + return ( + + + + + 创建账号 + + + + + + setUsername(e.target.value)} + placeholder="用户名" + autoComplete="username" + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + /> + setNickname(e.target.value)} + placeholder="昵称(可选)" + autoComplete="nickname" + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + /> + setPassword(e.target.value)} + type="password" + placeholder="密码(至少 8 位)" + autoComplete="new-password" + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + /> + setEmail(e.target.value)} + placeholder={emailRegisterEnabled ? "电子邮箱" : "电子邮箱(可选)"} + autoComplete="email" + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + /> + {emailRegisterEnabled && ( + + setCode(e.target.value)} + placeholder="6 位邮箱验证码" + maxLength={6} + className="flex-1" + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + /> + 0 || sendRegisterCodeMutation.isPending} + className="w-[120px] text-xs" + > + {registerCooldown > 0 ? `${registerCooldown}秒后重发` : "获取验证码"} + + + )} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {registerMutation.isPending ? ( + <> + + 注册中... + > + ) : ( + <> + + 创建账号 + > + )} + + + + + Already have an account?{" "} + + Sign in + + + + + ) +} diff --git a/frontend/components/auth/register-page.tsx b/frontend/components/auth/register-page.tsx new file mode 100644 index 00000000..cba9b374 --- /dev/null +++ b/frontend/components/auth/register-page.tsx @@ -0,0 +1,25 @@ +"use client" + +import {motion} from "motion/react" +import {RegisterForm} from "@/components/auth/register-form" +import {AuroraBackground} from "@/components/ui/aurora-background" + +export function RegisterPage() { + return ( + + + + + + ) +}
- 已向您的安全邮箱 {maskedEmail} 发送了登录验证码。 -
+ 已向您的安全邮箱 {maskedEmail} 发送了登录验证码。 +