feat(auth): 添加忘记密码功能

- 添加忘记密码页面,支持通过邮箱重置密码
- 添加重置密码页面
- 登录页面添加忘记密码链接
- 添加邮件发送功能
- 完善所有8种语言的翻译 (en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
This commit is contained in:
2026-03-09 20:45:18 +08:00
parent d2a3d32376
commit 5406543cbe
15 changed files with 558 additions and 19 deletions

View File

@@ -150,7 +150,23 @@
"loginFailed": "Anmeldung fehlgeschlagen", "loginFailed": "Anmeldung fehlgeschlagen",
"signUpFailed": "Registrierung fehlgeschlagen", "signUpFailed": "Registrierung fehlgeschlagen",
"fillAllFields": "Bitte füllen Sie alle Felder aus", "fillAllFields": "Bitte füllen Sie alle Felder aus",
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein" "enterCredentials": "Bitte geben Sie Benutzername und Passwort ein",
"forgotPassword": "Passwort vergessen",
"forgotPasswordHint": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.",
"sendResetEmail": "Reset-E-Mail senden",
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
"backToLogin": "Zurück zur Anmeldung",
"resetPassword": "Passwort zurücksetzen",
"newPassword": "Neues Passwort",
"invalidToken": "Ungültiger oder abgelaufener Link",
"invalidTokenHint": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
"requestNewToken": "Neuen Reset-Link anfordern",
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {

View File

@@ -150,7 +150,23 @@
"loginFailed": "Login failed", "loginFailed": "Login failed",
"signUpFailed": "Sign up failed", "signUpFailed": "Sign up failed",
"fillAllFields": "Please fill in all fields", "fillAllFields": "Please fill in all fields",
"enterCredentials": "Please enter username and password" "enterCredentials": "Please enter username and password",
"forgotPassword": "Forgot Password",
"forgotPasswordHint": "Enter your email address and we'll send you a link to reset your password.",
"sendResetEmail": "Send Reset Email",
"resetPasswordFailed": "Failed to send reset email",
"resetPasswordEmailSent": "Reset email sent successfully",
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
"checkYourEmail": "Check Your Email",
"backToLogin": "Back to Login",
"resetPassword": "Reset Password",
"newPassword": "New Password",
"invalidToken": "Invalid or Expired Link",
"invalidTokenHint": "This password reset link is invalid or has expired. Please request a new one.",
"requestNewToken": "Request New Reset Link",
"resetPasswordSuccess": "Password reset successfully",
"resetPasswordSuccessTitle": "Password Reset Complete",
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {

View File

@@ -150,7 +150,23 @@
"loginFailed": "Échec de la connexion", "loginFailed": "Échec de la connexion",
"signUpFailed": "Échec de l'inscription", "signUpFailed": "Échec de l'inscription",
"fillAllFields": "Veuillez remplir tous les champs", "fillAllFields": "Veuillez remplir tous les champs",
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe" "enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe",
"forgotPassword": "Mot de passe oublié",
"forgotPasswordHint": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
"sendResetEmail": "Envoyer l'e-mail de réinitialisation",
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
"checkYourEmail": "Vérifiez votre e-mail",
"backToLogin": "Retour à la connexion",
"resetPassword": "Réinitialiser le mot de passe",
"newPassword": "Nouveau mot de passe",
"invalidToken": "Lien invalide ou expiré",
"invalidTokenHint": "Ce lien de réinitialisation de mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.",
"requestNewToken": "Demander un nouveau lien de réinitialisation",
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {

View File

@@ -150,7 +150,23 @@
"loginFailed": "Accesso fallito", "loginFailed": "Accesso fallito",
"signUpFailed": "Registrazione fallita", "signUpFailed": "Registrazione fallita",
"fillAllFields": "Per favore compila tutti i campi", "fillAllFields": "Per favore compila tutti i campi",
"enterCredentials": "Per favore inserisci nome utente e password" "enterCredentials": "Per favore inserisci nome utente e password",
"forgotPassword": "Password Dimenticata",
"forgotPasswordHint": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password.",
"sendResetEmail": "Invia Email di Reset",
"resetPasswordFailed": "Impossibile inviare email di reset",
"resetPasswordEmailSent": "Email di reset inviata con successo",
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
"checkYourEmail": "Controlla la tua Email",
"backToLogin": "Torna al Login",
"resetPassword": "Reimposta Password",
"newPassword": "Nuova Password",
"invalidToken": "Link Non Valido o Scaduto",
"invalidTokenHint": "Questo link per reimpostare la password non è valido o è scaduto. Richiedine uno nuovo.",
"requestNewToken": "Richiedi Nuovo Link di Reset",
"resetPasswordSuccess": "Password reimpostata con successo",
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {

View File

@@ -150,7 +150,23 @@
"loginFailed": "ログインに失敗しました", "loginFailed": "ログインに失敗しました",
"signUpFailed": "新規登録に失敗しました", "signUpFailed": "新規登録に失敗しました",
"fillAllFields": "すべてのフィールドに入力してください", "fillAllFields": "すべてのフィールドに入力してください",
"enterCredentials": "ユーザー名とパスワードを入力してください" "enterCredentials": "ユーザー名とパスワードを入力してください",
"forgotPassword": "パスワードをお忘れですか",
"forgotPasswordHint": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
"sendResetEmail": "リセットメールを送信",
"resetPasswordFailed": "リセットメールの送信に失敗しました",
"resetPasswordEmailSent": "リセットメールを送信しました",
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
"checkYourEmail": "メールをご確認ください",
"backToLogin": "ログインに戻る",
"resetPassword": "パスワードをリセット",
"newPassword": "新しいパスワード",
"invalidToken": "無効または期限切れのリンク",
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
"requestNewToken": "新しいリセットリンクをリクエスト",
"resetPasswordSuccess": "パスワードのリセットに成功しました",
"resetPasswordSuccessTitle": "パスワードリセット完了",
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {

View File

@@ -150,7 +150,23 @@
"loginFailed": "로그인 실패", "loginFailed": "로그인 실패",
"signUpFailed": "회원가입 실패", "signUpFailed": "회원가입 실패",
"fillAllFields": "모든 필드를 입력하세요", "fillAllFields": "모든 필드를 입력하세요",
"enterCredentials": "사용자명과 비밀번호를 입력하세요" "enterCredentials": "사용자명과 비밀번호를 입력하세요",
"forgotPassword": "비밀번호 찾기",
"forgotPasswordHint": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
"sendResetEmail": "재설정 이메일 보내기",
"resetPasswordFailed": "재설정 이메일 전송 실패",
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
"checkYourEmail": "이메일을 확인하세요",
"backToLogin": "로그인으로 돌아가기",
"resetPassword": "비밀번호 재설정",
"newPassword": "새 비밀번호",
"invalidToken": "유효하지 않거나 만료된 링크",
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
"requestNewToken": "새 재설정 링크 요청",
"resetPasswordSuccess": "비밀번호 재설정 성공",
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {

View File

@@ -150,7 +150,23 @@
"loginFailed": "كىرىش مەغلۇپ بولدى", "loginFailed": "كىرىش مەغلۇپ بولدى",
"signUpFailed": "تىزىملىتىش مەغلۇپ بولدى", "signUpFailed": "تىزىملىتىش مەغلۇپ بولدى",
"fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ", "fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ",
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ" "enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ",
"forgotPassword": "پارولنى ئۇنتۇپ قالدىڭىزمۇ",
"forgotPasswordHint": "ئېلخەت ئادرېسىڭىزنى كىرگۈزۈڭ، بىز سىزگە پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئەۋەتىمىز.",
"sendResetEmail": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش",
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
"backToLogin": "كىرىشكە قايتىش",
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
"newPassword": "يېڭى پارول",
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {

View File

@@ -150,7 +150,23 @@
"loginFailed": "登录失败", "loginFailed": "登录失败",
"signUpFailed": "注册失败", "signUpFailed": "注册失败",
"fillAllFields": "请填写所有字段", "fillAllFields": "请填写所有字段",
"enterCredentials": "请输入用户名和密码" "enterCredentials": "请输入用户名和密码",
"forgotPassword": "忘记密码",
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
"sendResetEmail": "发送重置邮件",
"resetPasswordFailed": "发送重置邮件失败",
"resetPasswordEmailSent": "重置邮件已发送",
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
"checkYourEmail": "请查收邮件",
"backToLogin": "返回登录",
"resetPassword": "重置密码",
"newPassword": "新密码",
"invalidToken": "链接无效或已过期",
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
"requestNewToken": "重新申请重置链接",
"resetPasswordSuccess": "密码重置成功",
"resetPasswordSuccessTitle": "密码重置完成",
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {

View File

@@ -21,6 +21,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.1.1", "next": "16.1.1",
"next-intl": "^4.7.0", "next-intl": "^4.7.0",
"nodemailer": "^8.0.2",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
@@ -36,6 +37,7 @@
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.11",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/eslint-plugin": "^8.51.0",

19
pnpm-lock.yaml generated
View File

@@ -42,6 +42,9 @@ importers:
next-intl: next-intl:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.7.0(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) version: 4.7.0(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
nodemailer:
specifier: ^8.0.2
version: 8.0.2
pg: pg:
specifier: ^8.16.3 specifier: ^8.16.3
version: 8.16.3 version: 8.16.3
@@ -82,6 +85,9 @@ importers:
'@types/node': '@types/node':
specifier: ^25.0.3 specifier: ^25.0.3
version: 25.0.3 version: 25.0.3
'@types/nodemailer':
specifier: ^7.0.11
version: 7.0.11
'@types/react': '@types/react':
specifier: 19.2.7 specifier: 19.2.7
version: 19.2.7 version: 19.2.7
@@ -1049,6 +1055,9 @@ packages:
'@types/node@25.0.3': '@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
'@types/pg@8.15.6': '@types/pg@8.15.6':
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
@@ -2661,6 +2670,10 @@ packages:
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@8.0.2:
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0: normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4393,6 +4406,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
'@types/nodemailer@7.0.11':
dependencies:
'@types/node': 25.0.3
'@types/pg@8.15.6': '@types/pg@8.15.6':
dependencies: dependencies:
'@types/node': 25.0.3 '@types/node': 25.0.3
@@ -6068,6 +6085,8 @@ snapshots:
node-releases@2.0.27: {} node-releases@2.0.27: {}
nodemailer@8.0.2: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
nypm@0.6.2: nypm@0.6.2:

View File

@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function ForgotPasswordPage() {
const t = useTranslations("auth");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const handleResetRequest = async () => {
if (!email) {
toast.error(t("emailRequired"));
return;
}
setLoading(true);
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (error) {
toast.error(error.message ?? t("resetPasswordFailed"));
} else {
setSent(true);
toast.success(t("resetPasswordEmailSent"));
}
setLoading(false);
};
if (sent) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("checkYourEmail")}
</h1>
<p className="text-center text-gray-600">
{t("resetPasswordEmailSentHint")}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">
{t("forgotPassword")}
</h1>
<p className="text-center text-gray-600 text-sm">
{t("forgotPasswordHint")}
</p>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
type="email"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</VStack>
<PrimaryButton
onClick={handleResetRequest}
loading={loading}
fullWidth
>
{t("sendResetEmail")}
</PrimaryButton>
<Link
href="/login"
className="text-center text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}

View File

@@ -84,6 +84,13 @@ export default function LoginPage() {
/> />
</VStack> </VStack>
<Link
href="/forgot-password"
className="text-sm text-gray-500 hover:text-primary-500 self-end"
>
{t("forgotPassword")}
</Link>
<PrimaryButton <PrimaryButton
onClick={handleLogin} onClick={handleLogin}
loading={loading} loading={loading}

View File

@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function ResetPasswordPage() {
const t = useTranslations("auth");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const handleResetPassword = async () => {
if (!password || !confirmPassword) {
toast.error(t("fillAllFields"));
return;
}
if (password !== confirmPassword) {
toast.error(t("passwordsNotMatch"));
return;
}
if (password.length < 8) {
toast.error(t("passwordTooShort"));
return;
}
if (!token) {
toast.error(t("invalidToken"));
return;
}
setLoading(true);
const { error } = await authClient.resetPassword({
newPassword: password,
token,
});
if (error) {
toast.error(error.message ?? t("resetPasswordFailed"));
} else {
setSuccess(true);
toast.success(t("resetPasswordSuccess"));
setTimeout(() => {
router.push("/login");
}, 2000);
}
setLoading(false);
};
if (success) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("resetPasswordSuccessTitle")}
</h1>
<p className="text-center text-gray-600">
{t("resetPasswordSuccessHint")}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
if (!token) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("invalidToken")}
</h1>
<p className="text-center text-gray-600">
{t("invalidTokenHint")}
</p>
<Link
href="/forgot-password"
className="text-primary-500 hover:underline"
>
{t("requestNewToken")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">
{t("resetPassword")}
</h1>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
type="password"
placeholder={t("newPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Input
type="password"
placeholder={t("confirmPassword")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</VStack>
<PrimaryButton
onClick={handleResetPassword}
loading={loading}
fullWidth
>
{t("resetPassword")}
</PrimaryButton>
<Link
href="/login"
className="text-center text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}

View File

@@ -3,19 +3,42 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js"; import { nextCookies } from "better-auth/next-js";
import { prisma } from "./lib/db"; import { prisma } from "./lib/db";
import { username } from "better-auth/plugins"; import { username } from "better-auth/plugins";
import {
sendEmail,
generateVerificationEmailHtml,
generateResetPasswordEmailHtml,
} from "./lib/email";
export const auth = betterAuth({ export const auth = betterAuth({
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: "postgresql" provider: "postgresql",
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
void sendEmail({
to: user.email,
subject: "重置您的密码 - Learn Languages",
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
});
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
void sendEmail({
to: user.email,
subject: "验证您的邮箱 - Learn Languages",
html: generateVerificationEmailHtml(url, user.name || "用户"),
});
},
}, },
socialProviders: { socialProviders: {
github: { github: {
clientId: process.env.GITHUB_CLIENT_ID as string, clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
}, },
}, },
plugins: [nextCookies(), username()] plugins: [nextCookies(), username()],
}); });

104
src/lib/email.ts Normal file
View File

@@ -0,0 +1,104 @@
import nodemailer from "nodemailer";
import { createLogger } from "@/lib/logger";
const log = createLogger("email");
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === "true",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
interface SendEmailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
export async function sendEmail({ to, subject, html, text }: SendEmailOptions) {
try {
const info = await transporter.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to,
subject,
html,
text,
});
log.info("Email sent", { to, subject, messageId: info.messageId });
return { success: true, messageId: info.messageId };
} catch (error) {
log.error("Failed to send email", { to, subject, error });
return { success: false, error };
}
}
export function generateVerificationEmailHtml(url: string, userName: string) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.button { display: inline-block; padding: 12px 24px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; }
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>验证您的邮箱地址</h1>
<p>您好,${userName}</p>
<p>感谢您注册。请点击下方按钮验证您的邮箱地址:</p>
<p>
<a href="${url}" class="button">验证邮箱</a>
</p>
<p>或者复制以下链接到浏览器:</p>
<p style="word-break: break-all; color: #666;">${url}</p>
<p>此链接将在 24 小时后过期。</p>
<div class="footer">
<p>如果您没有注册此账户,请忽略此邮件。</p>
</div>
</div>
</body>
</html>
`;
}
export function generateResetPasswordEmailHtml(url: string, userName: string) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.button { display: inline-block; padding: 12px 24px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; }
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>重置您的密码</h1>
<p>您好,${userName}</p>
<p>我们收到了重置您账户密码的请求。请点击下方按钮设置新密码:</p>
<p>
<a href="${url}" class="button">重置密码</a>
</p>
<p>或者复制以下链接到浏览器:</p>
<p style="word-break: break-all; color: #666;">${url}</p>
<p>此链接将在 1 小时后过期。</p>
<div class="footer">
<p>如果您没有请求重置密码,请忽略此邮件,您的密码不会被更改。</p>
</div>
</div>
</body>
</html>
`;
}