From 0b40dcdb14f481f9b26ef86346d3aa91189ea197 Mon Sep 17 00:00:00 2001 From: keshav-005 Date: Wed, 22 Apr 2026 21:17:57 +0530 Subject: [PATCH] Add password reset flow --- app/(auth)/forgot-password/page.tsx | 75 +++++++++++++++++++ app/(auth)/reset-password/page.tsx | 110 ++++++++++++++++++++++++++++ app/(auth)/sign-in/page.tsx | 10 ++- lib/actions/auth.actions.ts | 39 ++++++++++ lib/better-auth/auth.ts | 17 ++++- lib/nodemailer/reset-password.ts | 47 ++++++++++++ middleware/index.ts | 4 +- 7 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 app/(auth)/forgot-password/page.tsx create mode 100644 app/(auth)/reset-password/page.tsx create mode 100644 lib/nodemailer/reset-password.ts diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..b3af01f --- /dev/null +++ b/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import InputField from '@/components/forms/InputField'; +import FooterLink from '@/components/forms/FooterLink'; +import OpenDevSocietyBranding from '@/components/OpenDevSocietyBranding'; +import { requestPasswordResetEmail } from '@/lib/actions/auth.actions'; + +type ForgotPasswordFormData = { + email: string; +}; + +const ForgotPasswordPage = () => { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + email: '', + }, + mode: 'onBlur', + }); + + const onSubmit = async (data: ForgotPasswordFormData) => { + const result = await requestPasswordResetEmail(data); + + if (result.success) { + toast.success('If an account exists for that email, a reset link has been sent.'); + return; + } + + toast.error('Password reset unavailable', { + description: result.error ?? 'Unable to start password reset.', + }); + }; + + return ( + <> +

Forgot your password?

+

+ Enter your email address and we'll send you a password reset link. +

+ +
+ + + + + + + + + ); +}; + +export default ForgotPasswordPage; diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..4a95672 --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import InputField from '@/components/forms/InputField'; +import FooterLink from '@/components/forms/FooterLink'; +import OpenDevSocietyBranding from '@/components/OpenDevSocietyBranding'; +import { resetPasswordWithToken } from '@/lib/actions/auth.actions'; + +type ResetPasswordFormData = { + newPassword: string; + confirmPassword: string; +}; + +const ResetPasswordPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get('token') ?? ''; + const error = searchParams.get('error'); + + const { + register, + watch, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + newPassword: '', + confirmPassword: '', + }, + mode: 'onBlur', + }); + + const newPassword = watch('newPassword'); + + useEffect(() => { + if (error === 'INVALID_TOKEN') { + toast.error('Reset link is invalid or expired.'); + } + }, [error]); + + const onSubmit = async (data: ResetPasswordFormData) => { + if (!token) { + toast.error('Reset link is invalid or expired.'); + return; + } + + const result = await resetPasswordWithToken({ + token, + newPassword: data.newPassword, + }); + + if (result.success) { + toast.success('Password updated. You can sign in now.'); + router.push('/sign-in'); + return; + } + + toast.error('Password reset failed', { + description: result.error ?? 'Unable to reset your password.', + }); + }; + + return ( + <> +

Choose a new password

+

+ Enter a new password for your account. +

+ +
+ + + + value === newPassword || 'Passwords do not match', + }} + /> + + + + + + + + ); +}; + +export default ResetPasswordPage; diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index 75e2780..1514cf9 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -4,9 +4,9 @@ import { useForm } from 'react-hook-form'; import { Button } from '@/components/ui/button'; import InputField from '@/components/forms/InputField'; import FooterLink from '@/components/forms/FooterLink'; -import { signInWithEmail, signUpWithEmail } from "@/lib/actions/auth.actions"; +import { signInWithEmail } from "@/lib/actions/auth.actions"; import { toast } from "sonner"; -import { signInEmail } from "better-auth/api"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import OpenDevSocietyBranding from "@/components/OpenDevSocietyBranding"; import React from "react"; @@ -73,6 +73,12 @@ const SignIn = () => { validation={{ required: 'Password is required', minLength: 8 }} /> +
+ + Forgot password? + +
+ diff --git a/lib/actions/auth.actions.ts b/lib/actions/auth.actions.ts index 6825b75..4750a1b 100644 --- a/lib/actions/auth.actions.ts +++ b/lib/actions/auth.actions.ts @@ -58,6 +58,45 @@ export const signInWithEmail = async ({ email, password }: SignInFormData) => { } } +export const requestPasswordResetEmail = async ({ email }: { email: string }) => { + if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) { + return { success: false, error: 'Password reset email is not configured.' } + } + + try { + const baseUrl = process.env.BETTER_AUTH_URL ?? 'http://localhost:3000'; + await auth.api.requestPasswordReset({ + body: { + email, + redirectTo: `${baseUrl}/reset-password`, + }, + }); + + return { success: true } + } catch (e) { + console.log('Password reset request failed', e) + return { success: false, error: 'Unable to send password reset email.' } + } +} + +export const resetPasswordWithToken = async ( + { token, newPassword }: { token: string; newPassword: string } +) => { + try { + await auth.api.resetPassword({ + body: { + token, + newPassword, + }, + }); + + return { success: true } + } catch (e) { + console.log('Password reset failed', e) + return { success: false, error: 'Reset link is invalid or expired.' } + } +} + export const signOut = async () => { try { await auth.api.signOut({ headers: await headers() }); diff --git a/lib/better-auth/auth.ts b/lib/better-auth/auth.ts index 8ad0f08..ff7f1d2 100644 --- a/lib/better-auth/auth.ts +++ b/lib/better-auth/auth.ts @@ -2,6 +2,7 @@ import { betterAuth } from "better-auth"; import {mongodbAdapter} from "better-auth/adapters/mongodb"; import {connectToDatabase} from "@/database/mongoose"; import {nextCookies} from "better-auth/next-js"; +import { sendPasswordResetEmail } from "@/lib/nodemailer/reset-password"; let authInstance: ReturnType | null = null; @@ -14,13 +15,14 @@ export const getAuth = async () => { const mongoose = await connectToDatabase(); const db = mongoose.connection; + const database = db.db; - if (!db) { + if (!db || !database) { throw new Error("MongoDB connection not found!"); } authInstance = betterAuth({ - database: mongodbAdapter(db as any), + database: mongodbAdapter(database), secret: process.env.BETTER_AUTH_SECRET, baseURL: process.env.BETTER_AUTH_URL, emailAndPassword: { @@ -30,6 +32,15 @@ export const getAuth = async () => { minPasswordLength: 8, maxPasswordLength: 128, autoSignIn: true, + sendResetPassword: async ({ user, url }) => { + void sendPasswordResetEmail({ + email: user.email, + name: user.name, + resetUrl: url, + }).catch((error) => { + console.error('Failed to queue password reset email:', error); + }); + }, }, plugins: [nextCookies()], @@ -38,4 +49,4 @@ export const getAuth = async () => { return authInstance; } -export const auth = await getAuth(); \ No newline at end of file +export const auth = await getAuth(); diff --git a/lib/nodemailer/reset-password.ts b/lib/nodemailer/reset-password.ts new file mode 100644 index 0000000..5255e22 --- /dev/null +++ b/lib/nodemailer/reset-password.ts @@ -0,0 +1,47 @@ +import { transporter } from "@/lib/nodemailer"; + +export const sendPasswordResetEmail = async ( + { email, name, resetUrl }: { email: string; name?: string | null; resetUrl: string } +) => { + try { + if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) { + throw new Error('Email credentials not configured'); + } + + const firstName = name?.trim().split(' ')[0] || 'there'; + const html = ` +
+
+

Reset your password

+

Hi ${firstName},

+

+ We received a request to reset your Openstock password. Use the button below to choose a new one. +

+ + Reset password + +

+ If you did not request this, you can safely ignore this email. +

+
+
+ `; + + const info = await transporter.sendMail({ + from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`, + to: email, + subject: 'Reset your Openstock password', + text: `Reset your password: ${resetUrl}`, + html, + }); + + console.log('Password reset email sent successfully:', info.messageId); + return info; + } catch (error) { + console.error('Failed to send password reset email:', error); + throw error; + } +}; diff --git a/middleware/index.ts b/middleware/index.ts index bb3be60..9a66eba 100644 --- a/middleware/index.ts +++ b/middleware/index.ts @@ -14,6 +14,6 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ - '/((?!api|_next/static|_next/image|favicon.ico|sign-in|sign-up|assets).*)', + '/((?!api|_next/static|_next/image|favicon.ico|sign-in|sign-up|forgot-password|reset-password|assets).*)', ], -}; \ No newline at end of file +};