From a5e7b2069926e5071bb7e5ea9f0037e60ac248ec Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Thu, 7 May 2026 11:04:06 +0500 Subject: [PATCH] Add forgot-password and reset-password flow Users can now request a password reset link via email. Better Auth sendResetPassword callback sends a branded email via Resend. Login page shows success notice after password is set. Co-Authored-By: Claude Sonnet 4.6 --- .../forgot-password/forgot-password-form.tsx | 88 ++++++++++++++ src/app/(auth)/forgot-password/page.tsx | 23 ++++ src/app/(auth)/login/login-form.tsx | 8 +- src/app/(auth)/login/page.tsx | 8 ++ src/app/(auth)/reset-password/page.tsx | 26 +++++ .../reset-password/reset-password-form.tsx | 110 ++++++++++++++++++ src/lib/auth.ts | 5 +- src/lib/email.ts | 16 +++ 8 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 src/app/(auth)/forgot-password/forgot-password-form.tsx create mode 100644 src/app/(auth)/forgot-password/page.tsx create mode 100644 src/app/(auth)/reset-password/page.tsx create mode 100644 src/app/(auth)/reset-password/reset-password-form.tsx diff --git a/src/app/(auth)/forgot-password/forgot-password-form.tsx b/src/app/(auth)/forgot-password/forgot-password-form.tsx new file mode 100644 index 0000000..4207a33 --- /dev/null +++ b/src/app/(auth)/forgot-password/forgot-password-form.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { authClient } from "@/lib/auth-client"; + +const inputStyle: React.CSSProperties = { + border: "2px solid var(--border)", + color: "var(--foreground)", + fontFamily: "var(--font-sans)", + outline: "none", +}; + +export function ForgotPasswordForm() { + const [email, setEmail] = useState(""); + const [sent, setSent] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + await authClient.requestPasswordReset({ + email, + redirectTo: "/reset-password", + }); + setLoading(false); + setSent(true); + } + + if (sent) { + return ( +
+

+ Письмо со ссылкой для сброса пароля отправлено на{" "} + {email}. +

+

+ Проверьте папку «Спам», если письмо не пришло в течение пары минут. +

+ + Вернуться к входу + +
+ ); + } + + return ( +
+
+

+ Введите email — мы пришлём ссылку для задания нового пароля. +

+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 text-sm bg-transparent" + style={inputStyle} + onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")} + onBlur={(e) => (e.target.style.borderColor = "var(--border)")} + placeholder="you@example.com" + /> +
+ {error && ( +

{error}

+ )} + +

+ + Вернуться к входу + +

+
+ ); +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..48a1a04 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,23 @@ +import { getSetting } from "@/lib/settings"; +import { ForgotPasswordForm } from "./forgot-password-form"; + +export default async function ForgotPasswordPage() { + const schoolName = await getSetting("schoolName"); + return ( +
+
+
+

+ {schoolName} +

+

+ Образовательная платформа +

+
+
+ +
+
+
+ ); +} diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx index 6511d21..05fdf5b 100644 --- a/src/app/(auth)/login/login-form.tsx +++ b/src/app/(auth)/login/login-form.tsx @@ -95,12 +95,14 @@ export function LoginForm() { > {loading ? "Вход..." : "Войти"} -

- Нет аккаунта?{" "} +

+ + Забыли пароль? + Зарегистрироваться -

+
); } diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 1686908..15c0ee5 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -22,6 +22,14 @@ export default async function LoginPage({ Образовательная платформа

+ {notice === "password_reset" && ( +
+ Пароль успешно задан. Войдите с новым паролем. +
+ )} {notice === "registration_closed" && (
+
+
+

+ {schoolName} +

+

+ Образовательная платформа +

+
+
+ + + +
+
+
+ ); +} diff --git a/src/app/(auth)/reset-password/reset-password-form.tsx b/src/app/(auth)/reset-password/reset-password-form.tsx new file mode 100644 index 0000000..ec15adf --- /dev/null +++ b/src/app/(auth)/reset-password/reset-password-form.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { authClient } from "@/lib/auth-client"; + +const inputStyle: React.CSSProperties = { + border: "2px solid var(--border)", + color: "var(--foreground)", + fontFamily: "var(--font-sans)", + outline: "none", +}; + +export function ResetPasswordForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get("token") ?? ""; + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + if (!token) { + return ( +
+

+ Ссылка недействительна или устарела. +

+ + Запросить новую ссылку + +
+ ); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (password !== confirm) { + setError("Пароли не совпадают"); + return; + } + if (password.length < 8) { + setError("Пароль должен быть не короче 8 символов"); + return; + } + setLoading(true); + const result = await authClient.resetPassword({ newPassword: password, token }); + setLoading(false); + if (result.error) { + setError("Ссылка устарела или уже использована. Запросите новую."); + return; + } + router.push("/login?notice=password_reset"); + } + + return ( +
+

+ Задайте новый пароль для вашего аккаунта. +

+
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full px-3 py-2 text-sm bg-transparent" + style={inputStyle} + onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")} + onBlur={(e) => (e.target.style.borderColor = "var(--border)")} + placeholder="Минимум 8 символов" + /> +
+
+ + setConfirm(e.target.value)} + required + className="w-full px-3 py-2 text-sm bg-transparent" + style={inputStyle} + onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")} + onBlur={(e) => (e.target.style.borderColor = "var(--border)")} + placeholder="••••••••" + /> +
+ {error && ( +

{error}

+ )} + +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5c86f60..40b4f41 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,7 +3,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma"; import { admin } from "better-auth/plugins"; import { prisma } from "./prisma"; import bcrypt from "bcryptjs"; -import { sendWelcomeEmail } from "./email"; +import { sendWelcomeEmail, sendPasswordResetEmail } from "./email"; export const auth = betterAuth({ database: prismaAdapter(prisma, { @@ -16,6 +16,9 @@ export const auth = betterAuth({ hash: (password) => bcrypt.hash(password, 10), verify: ({ hash, password }) => bcrypt.compare(password, hash), }, + sendResetPassword: async ({ user, url }) => { + await sendPasswordResetEmail(user.email, user.name, url); + }, }, databaseHooks: { user: { diff --git a/src/lib/email.ts b/src/lib/email.ts index 447629e..3546458 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -189,3 +189,19 @@ export async function sendTestEmail(to: string) { `, school), }).catch((e) => console.error("[email] sendTestEmail:", e)); } + +export async function sendPasswordResetEmail(to: string, name: string, resetUrl: string) { + const school = await getSchoolName(); + await getResend().emails.send({ + from: FROM, + to, + subject: `Сброс пароля — ${school}`, + html: base(` +

Привет, ${name}!

+

Вы запросили сброс пароля для вашего аккаунта на платформе ${school}.

+

Нажмите на кнопку ниже чтобы задать новый пароль. Ссылка действительна 1 час.

+

Если вы не запрашивали сброс — просто проигнорируйте это письмо.

+ ${btn(resetUrl, "Задать новый пароль")} + `, school), + }).catch((e) => console.error("[email] sendPasswordResetEmail:", e)); +}