fix: 添加邮箱验证重发功能

- 登录时检测 403 错误(邮箱未验证)
- 显示重发验证邮件按钮
- 修复邮件发送失败时静默忽略的问题
- 添加 8 种语言的验证相关翻译
This commit is contained in:
2026-03-10 19:38:54 +08:00
parent 57ad1b8699
commit 6f4b123a84
10 changed files with 111 additions and 15 deletions

View File

@@ -175,7 +175,12 @@
"requestNewToken": "Neuen Reset-Link anfordern", "requestNewToken": "Neuen Reset-Link anfordern",
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt", "resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen", "resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden." "resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
"emailNotVerified": "Bitte verifizieren Sie Ihre E-Mail-Adresse",
"emailNotVerifiedHint": "Ihre E-Mail-Adresse wurde nicht verifiziert. Bitte überprüfen Sie Ihren Posteingang oder fordern Sie eine neue Verifizierungs-E-Mail an.",
"resendVerification": "Verifizierungs-E-Mail erneut senden",
"resendSuccess": "Verifizierungs-E-Mail gesendet! Bitte überprüfen Sie Ihren Posteingang.",
"resendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden"
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {

View File

@@ -166,7 +166,12 @@
"requestNewToken": "Request New Reset Link", "requestNewToken": "Request New Reset Link",
"resetPasswordSuccess": "Password reset successfully", "resetPasswordSuccess": "Password reset successfully",
"resetPasswordSuccessTitle": "Password Reset Complete", "resetPasswordSuccessTitle": "Password Reset Complete",
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password." "resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password.",
"emailNotVerified": "Please verify your email address",
"emailNotVerifiedHint": "Your email has not been verified. Please check your inbox or request a new verification email.",
"resendVerification": "Resend Verification Email",
"resendSuccess": "Verification email sent! Please check your inbox.",
"resendFailed": "Failed to send verification email"
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {

View File

@@ -175,7 +175,12 @@
"requestNewToken": "Demander un nouveau lien de réinitialisation", "requestNewToken": "Demander un nouveau lien de réinitialisation",
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès", "resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée", "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." "resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.",
"emailNotVerified": "Veuillez vérifier votre adresse e-mail",
"emailNotVerifiedHint": "Votre adresse e-mail n'a pas été vérifiée. Veuillez vérifier votre boîte de réception ou demander un nouvel e-mail de vérification.",
"resendVerification": "Renvoyer l'e-mail de vérification",
"resendSuccess": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
"resendFailed": "Échec de l'envoi de l'e-mail de vérification"
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {

View File

@@ -175,7 +175,12 @@
"requestNewToken": "Richiedi Nuovo Link di Reset", "requestNewToken": "Richiedi Nuovo Link di Reset",
"resetPasswordSuccess": "Password reimpostata con successo", "resetPasswordSuccess": "Password reimpostata con successo",
"resetPasswordSuccessTitle": "Reimpostazione Password Completata", "resetPasswordSuccessTitle": "Reimpostazione Password Completata",
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password." "resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password.",
"emailNotVerified": "Verifica il tuo indirizzo email",
"emailNotVerifiedHint": "Il tuo indirizzo email non è stato verificato. Controlla la tua casella di posta o richiedi una nuova email di verifica.",
"resendVerification": "Invia di nuovo email di verifica",
"resendSuccess": "Email di verifica inviata! Controlla la tua casella di posta.",
"resendFailed": "Impossibile inviare l'email di verifica"
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {

View File

@@ -166,7 +166,12 @@
"requestNewToken": "新しいリセットリンクをリクエスト", "requestNewToken": "新しいリセットリンクをリクエスト",
"resetPasswordSuccess": "パスワードのリセットに成功しました", "resetPasswordSuccess": "パスワードのリセットに成功しました",
"resetPasswordSuccessTitle": "パスワードリセット完了", "resetPasswordSuccessTitle": "パスワードリセット完了",
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。" "resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。",
"emailNotVerified": "メールアドレスを確認してください",
"emailNotVerifiedHint": "メールアドレスが確認されていません。受信トレイをご確認いただくか、新しい確認メールをリクエストしてください。",
"resendVerification": "確認メールを再送信",
"resendSuccess": "確認メールを送信しました!受信トレイをご確認ください。",
"resendFailed": "確認メールの送信に失敗しました"
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {

View File

@@ -175,7 +175,12 @@
"requestNewToken": "새 재설정 링크 요청", "requestNewToken": "새 재설정 링크 요청",
"resetPasswordSuccess": "비밀번호 재설정 성공", "resetPasswordSuccess": "비밀번호 재설정 성공",
"resetPasswordSuccessTitle": "비밀번호 재설정 완료", "resetPasswordSuccessTitle": "비밀번호 재설정 완료",
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다." "resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다.",
"emailNotVerified": "이메일 주소를 인증해 주세요",
"emailNotVerifiedHint": "이메일이 인증되지 않았습니다. 받은 편지함을 확인하거나 새 인증 이메일을 요청해 주세요.",
"resendVerification": "인증 이메일 다시 보내기",
"resendSuccess": "인증 이메일이 발송되었습니다! 받은 편지함을 확인해 주세요.",
"resendFailed": "인증 이메일 발송에 실패했습니다"
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {

View File

@@ -175,7 +175,12 @@
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش", "requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى", "resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى", "resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ." "resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ.",
"emailNotVerified": "ئېلخەت ئادرېسىڭىزنى دەلىللەڭ",
"emailNotVerifiedHint": "ئېلخەت ئادرېسىڭىز دەلىللەنمىگەن. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ ياكى يېڭى دەلىللەش ئېلخېتى سوراڭ.",
"resendVerification": "دەلىللەش ئېلخېتىنى قايتا ئەۋەتىش",
"resendSuccess": "دەلىللەش ئېلخېتى ئەۋەتىلدى! ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"resendFailed": "دەلىللەش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى"
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {

View File

@@ -166,7 +166,12 @@
"requestNewToken": "重新申请重置链接", "requestNewToken": "重新申请重置链接",
"resetPasswordSuccess": "密码重置成功", "resetPasswordSuccess": "密码重置成功",
"resetPasswordSuccessTitle": "密码重置完成", "resetPasswordSuccessTitle": "密码重置完成",
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。" "resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。",
"emailNotVerified": "请验证您的邮箱地址",
"emailNotVerifiedHint": "您的邮箱尚未验证。请检查收件箱或重新发送验证邮件。",
"resendVerification": "重新发送验证邮件",
"resendSuccess": "验证邮件已发送!请检查您的收件箱。",
"resendFailed": "发送验证邮件失败"
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {

View File

@@ -8,7 +8,7 @@ import { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card"; import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input"; import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button"; import { PrimaryButton, LinkButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack"; import { VStack } from "@/design-system/layout/stack";
export default function LoginPage() { export default function LoginPage() {
@@ -16,6 +16,9 @@ export default function LoginPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [showResendOption, setShowResendOption] = useState(false);
const [unverifiedEmail, setUnverifiedEmail] = useState("");
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect"); const redirectTo = searchParams.get("redirect");
@@ -25,10 +28,31 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (!isPending && session?.user?.username && !redirectTo) { if (!isPending && session?.user?.username && !redirectTo) {
router.push("/folders"); router.push("/decks");
} }
}, [session, isPending, router, redirectTo]); }, [session, isPending, router, redirectTo]);
const handleResendVerification = async () => {
if (!unverifiedEmail) return;
setResendLoading(true);
try {
const { error } = await authClient.sendVerificationEmail({
email: unverifiedEmail,
callbackURL: "/login",
});
if (error) {
toast.error(t("resendFailed"));
} else {
toast.success(t("resendSuccess"));
setShowResendOption(false);
}
} finally {
setResendLoading(false);
}
};
const handleLogin = async () => { const handleLogin = async () => {
if (!username || !password) { if (!username || !password) {
toast.error(t("enterCredentials")); toast.error(t("enterCredentials"));
@@ -36,6 +60,7 @@ export default function LoginPage() {
} }
setLoading(true); setLoading(true);
setShowResendOption(false);
try { try {
if (username.includes("@")) { if (username.includes("@")) {
const { error } = await authClient.signIn.email({ const { error } = await authClient.signIn.email({
@@ -43,7 +68,13 @@ export default function LoginPage() {
password: password, password: password,
}); });
if (error) { if (error) {
if (error.status === 403) {
setUnverifiedEmail(username);
setShowResendOption(true);
toast.error(t("emailNotVerified"));
} else {
toast.error(error.message ?? t("loginFailed")); toast.error(error.message ?? t("loginFailed"));
}
return; return;
} }
} else { } else {
@@ -52,11 +83,15 @@ export default function LoginPage() {
password: password, password: password,
}); });
if (error) { if (error) {
if (error.status === 403) {
toast.error(t("emailNotVerified"));
} else {
toast.error(error.message ?? t("loginFailed")); toast.error(error.message ?? t("loginFailed"));
}
return; return;
} }
} }
router.push(redirectTo ?? "/folders"); router.push(redirectTo ?? "/decks");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -91,6 +126,21 @@ export default function LoginPage() {
{t("forgotPassword")} {t("forgotPassword")}
</Link> </Link>
{showResendOption && (
<div className="w-full p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-sm">
<p className="text-yellow-800 dark:text-yellow-200 mb-2">
{t("emailNotVerifiedHint")}
</p>
<LinkButton
onClick={handleResendVerification}
loading={resendLoading}
size="sm"
>
{t("resendVerification")}
</LinkButton>
</div>
)}
<PrimaryButton <PrimaryButton
onClick={handleLogin} onClick={handleLogin}
loading={loading} loading={loading}

View File

@@ -18,21 +18,27 @@ export const auth = betterAuth({
enabled: true, enabled: true,
requireEmailVerification: true, requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => { sendResetPassword: async ({ user, url }) => {
void sendEmail({ const result = await sendEmail({
to: user.email, to: user.email,
subject: "重置您的密码 - Learn Languages", subject: "重置您的密码 - Learn Languages",
html: generateResetPasswordEmailHtml(url, user.name || "用户"), html: generateResetPasswordEmailHtml(url, user.name || "用户"),
}); });
if (!result.success) {
console.error("[email] Failed to send reset password email:", result.error);
}
}, },
}, },
emailVerification: { emailVerification: {
sendOnSignUp: true, sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => { sendVerificationEmail: async ({ user, url }) => {
void sendEmail({ const result = await sendEmail({
to: user.email, to: user.email,
subject: "验证您的邮箱 - Learn Languages", subject: "验证您的邮箱 - Learn Languages",
html: generateVerificationEmailHtml(url, user.name || "用户"), html: generateVerificationEmailHtml(url, user.name || "用户"),
}); });
if (!result.success) {
console.error("[email] Failed to send verification email:", result.error);
}
}, },
}, },
socialProviders: { socialProviders: {