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 (
+
+ );
+}
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 (
+
+ );
+}
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));
+}