🎯 项目概述 - 一个开源的 AI 工具导航站,帮助用户发现和探索优秀的 AI 工具 - 基于 Next.js 16 + React 19 构建的现代化全栈应用 ✨ 核心功能 - AI 工具目录:分类展示、搜索筛选、工具详情页 - 工具提交:用户可提交新工具,社区驱动 - 管理后台:工具审核、用户管理、角色权限控制 - 博客系统:MDX 驱动,AI 资讯和教程 - 国际化支持:中文/英文双语 (next-intl) - 用户认证:Better Auth + Google OAuth - 深色/浅色主题切换 🛠 技术栈 - 前端:Next.js 16 (App Router) + React 19 + TypeScript - 样式:Tailwind CSS + Framer Motion 动画 - UI 组件:自定义组件库 + Radix UI - 表单验证:React Hook Form + Zod - 数据库:PostgreSQL + Drizzle ORM - 认证:Better Auth (邮箱密码 + Google OAuth) - 邮件:Resend (验证邮件 + Newsletter) - 存储:Cloudflare R2 / AWS S3 兼容 📁 项目结构 - src/app/[locale]/(auth) - 认证页面 - src/app/[locale]/(marketing) - 营销页面 (首页、博客、工具目录) - src/app/[locale]/(protected) - 受保护页面 (仪表板、个人资料) - src/app/[locale]/(admin) - 管理后台 - src/features - 功能模块 - src/lib - 核心业务逻辑 - messages - 国际化翻译文件 🗄 数据库 - 包含 user, session, account, verification, passwordResetToken 等核心表 - 支持 Drizzle ORM 迁移管理 📝 文档 - 完整的 CLAUDE.md 项目指南 - 环境变量配置示例 (.env.example) - MIT 开源许可证
189 lines
5.2 KiB
TypeScript
189 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { z } from "zod";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Button } from "@/components/button";
|
|
import { LocaleLink } from "@/components/locale-link";
|
|
import { FormShell } from "@/features/forms/components/form-shell";
|
|
import { AuthMessageCard } from "@/features/forms/components/auth-message-card";
|
|
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
import { Input } from "@/features/forms/components/input";
|
|
import { toast } from "sonner";
|
|
|
|
const resetPasswordSchema = z.object({
|
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
|
confirmPassword: z.string(),
|
|
}).refine((data) => data.password === data.confirmPassword, {
|
|
message: "Passwords don't match",
|
|
path: ["confirmPassword"],
|
|
});
|
|
|
|
type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
|
|
|
export default function ResetPasswordPage() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const t = useTranslations();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isValidToken, setIsValidToken] = useState<boolean | null>(null);
|
|
const token = searchParams.get("token");
|
|
|
|
const form = useForm<ResetPasswordInput>({
|
|
resolver: zodResolver(resetPasswordSchema),
|
|
defaultValues: {
|
|
password: "",
|
|
confirmPassword: "",
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setIsValidToken(false);
|
|
return;
|
|
}
|
|
|
|
// Verify token validity
|
|
fetch(`/api/auth/verify-reset-token?token=${token}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setIsValidToken(data.valid);
|
|
})
|
|
.catch(() => {
|
|
setIsValidToken(false);
|
|
});
|
|
}, [token]);
|
|
|
|
const onSubmit = async (data: ResetPasswordInput) => {
|
|
if (!token) {
|
|
toast.error("Invalid reset link");
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await fetch("/api/auth/reset-password", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
token,
|
|
password: data.password,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast.success("Password reset successfully!");
|
|
setTimeout(() => {
|
|
router.push("/login");
|
|
}, 2000);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.message || "Failed to reset password");
|
|
}
|
|
} catch (error) {
|
|
toast.error("An error occurred. Please try again.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
if (isValidToken === null) {
|
|
return (
|
|
<AuthMessageCard
|
|
title="Verifying..."
|
|
description="Please wait while we verify your reset link."
|
|
>
|
|
<div className="flex justify-center py-8">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground"></div>
|
|
</div>
|
|
</AuthMessageCard>
|
|
);
|
|
}
|
|
|
|
if (isValidToken === false) {
|
|
return (
|
|
<AuthMessageCard
|
|
title="Invalid or Expired Link"
|
|
description="This password reset link is invalid or has expired."
|
|
>
|
|
<p className="text-sm text-muted-foreground">
|
|
Please request a new password reset link.
|
|
</p>
|
|
<LocaleLink href="/forgot-password">
|
|
<Button className="w-full">
|
|
Request New Link
|
|
</Button>
|
|
</LocaleLink>
|
|
<LocaleLink href="/login">
|
|
<Button variant="outline" className="w-full">
|
|
Back to Login
|
|
</Button>
|
|
</LocaleLink>
|
|
</AuthMessageCard>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<FormShell<ResetPasswordInput>
|
|
form={form}
|
|
title="Reset your password"
|
|
description="Enter your new password below."
|
|
onSubmit={onSubmit}
|
|
submitText="Reset Password"
|
|
submitLoadingText="Resetting..."
|
|
isLoading={isLoading}
|
|
footer={
|
|
<div className="text-center">
|
|
<LocaleLink
|
|
href="/login"
|
|
className="text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
Back to Login
|
|
</LocaleLink>
|
|
</div>
|
|
}
|
|
>
|
|
<FormField
|
|
control={form.control}
|
|
name="password"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>New Password</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="password"
|
|
placeholder="Enter new password"
|
|
disabled={isLoading}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="confirmPassword"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Confirm Password</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="password"
|
|
placeholder="Confirm new password"
|
|
disabled={isLoading}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</FormShell>
|
|
);
|
|
}
|