feat: Implement username/password authentication and registration features

This commit is contained in:
beilunyang
2025-01-15 15:48:26 +08:00
parent 969d0ce334
commit 126a4cb948
24 changed files with 2643 additions and 67 deletions

View File

@@ -0,0 +1,342 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
import { Github, Loader2, KeyRound, User2 } from "lucide-react"
import { cn } from "@/lib/utils"
interface FormErrors {
username?: string
password?: string
confirmPassword?: string
}
export function LoginForm() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<FormErrors>({})
const { toast } = useToast()
const validateLoginForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = "请输入用户名"
if (!password) newErrors.password = "请输入密码"
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const validateRegisterForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = "请输入用户名"
if (!password) newErrors.password = "请输入密码"
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
if (!confirmPassword) newErrors.confirmPassword = "请确认密码"
if (password !== confirmPassword) newErrors.confirmPassword = "两次输入的密码不一致"
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleLogin = async () => {
if (!validateLoginForm()) return
setLoading(true)
try {
const result = await signIn("credentials", {
username,
password,
redirect: false,
})
if (result?.error) {
toast({
title: "登录失败",
description: "用户名或密码错误",
variant: "destructive",
})
setLoading(false)
return
}
window.location.href = "/"
} catch (error) {
toast({
title: "登录失败",
description: error instanceof Error ? error.message : "请稍后重试",
variant: "destructive",
})
setLoading(false)
}
}
const handleRegister = async () => {
if (!validateRegisterForm()) return
setLoading(true)
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
})
const data = await response.json() as { error?: string }
if (!response.ok) {
toast({
title: "注册失败",
description: data.error || "请稍后重试",
variant: "destructive",
})
setLoading(false)
return
}
// 注册成功后自动登录
const result = await signIn("credentials", {
username,
password,
redirect: false,
})
if (result?.error) {
toast({
title: "登录失败",
description: "自动登录失败,请手动登录",
variant: "destructive",
})
setLoading(false)
return
}
window.location.href = "/"
} catch (error) {
toast({
title: "注册失败",
description: error instanceof Error ? error.message : "请稍后重试",
variant: "destructive",
})
setLoading(false)
}
}
const handleGithubLogin = () => {
signIn("github", { callbackUrl: "/" })
}
const clearForm = () => {
setUsername("")
setPassword("")
setConfirmPassword("")
setErrors({})
}
return (
<Card className="w-[95%] max-w-lg border-2 border-primary/20">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl text-center bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
使 MoeMail
</CardTitle>
<CardDescription className="text-center">
()
</CardDescription>
</CardHeader>
<CardContent className="px-6">
<Tabs defaultValue="login" className="w-full" onValueChange={clearForm}>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="login"></TabsTrigger>
<TabsTrigger value="register"></TabsTrigger>
</TabsList>
<div className="min-h-[220px]">
<TabsContent value="login" className="space-y-4 mt-0">
<div className="space-y-3">
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<User2 className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.username && "border-destructive focus-visible:ring-destructive"
)}
placeholder="用户名"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<KeyRound className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.password && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="密码"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
</div>
<div className="space-y-3 pt-1">
<Button
className="w-full"
onClick={handleLogin}
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={handleGithubLogin}
>
<Github className="mr-2 h-4 w-4" />
使 GitHub
</Button>
</div>
</TabsContent>
<TabsContent value="register" className="space-y-4 mt-0">
<div className="space-y-3">
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<User2 className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.username && "border-destructive focus-visible:ring-destructive"
)}
placeholder="用户名"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<KeyRound className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.password && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="密码"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<KeyRound className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.confirmPassword && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="确认密码"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.confirmPassword && (
<p className="text-xs text-destructive">{errors.confirmPassword}</p>
)}
</div>
</div>
<div className="space-y-3 pt-1">
<Button
className="w-full"
onClick={handleRegister}
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</div>
</TabsContent>
</div>
</Tabs>
</CardContent>
</Card>
)
}

View File

@@ -2,23 +2,30 @@
import { Button } from "@/components/ui/button"
import Image from "next/image"
import { signIn, signOut, useSession } from "next-auth/react"
import { Github } from "lucide-react"
import { signOut, useSession } from "next-auth/react"
import { LogIn } from "lucide-react"
import { useRouter } from 'next/navigation'
import Link from "next/link"
import { cn } from "@/lib/utils"
export function SignButton() {
interface SignButtonProps {
size?: "default" | "lg"
}
export function SignButton({ size = "default" }: SignButtonProps) {
const router = useRouter()
const { data: session, status } = useSession()
const loading = status === "loading"
if (loading) {
return <div className="h-9" /> // 防止布局跳动
return <div className="h-9" />
}
if (!session?.user) {
return (
<Button onClick={() => signIn("github", { callbackUrl: "/moe" })} className="gap-2">
<Github className="w-4 h-4" />
使 GitHub
<Button onClick={() => router.push('/login')} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
<LogIn className={size === "lg" ? "w-5 h-5" : "w-4 h-4"} />
/
</Button>
)
}
@@ -40,7 +47,7 @@ export function SignButton() {
)}
<span className="text-sm">{session.user.name}</span>
</Link>
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline" className="flex-shrink-0">
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
</Button>
</div>

View File

@@ -1,9 +1,9 @@
"use client"
import { Button } from "@/components/ui/button"
import { Mail, Github } from "lucide-react"
import { Mail } from "lucide-react"
import { useRouter } from "next/navigation"
import { signIn } from "next-auth/react"
import { SignButton } from "../auth/sign-button"
interface ActionButtonProps {
isLoggedIn?: boolean
@@ -25,14 +25,5 @@ export function ActionButton({ isLoggedIn }: ActionButtonProps) {
)
}
return (
<Button
size="lg"
onClick={() => signIn("github", { callbackUrl: "/moe" })}
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
>
<Github className="w-5 h-5" />
使 GitHub
</Button>
)
return <SignButton size="lg" />
}

View File

@@ -47,10 +47,15 @@ export function ProfileCard({ user }: ProfileCardProps) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold truncate">{user.name}</h2>
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
<Github className="w-3 h-3" />
</div>
{
user.email && (
// 先简单实现,后续再完善
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
<Github className="w-3 h-3" />
</div>
)
}
</div>
<p className="text-sm text-muted-foreground truncate mt-1">
{user.email}

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
}

View File

@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }