From 0b40dcdb14f481f9b26ef86346d3aa91189ea197 Mon Sep 17 00:00:00 2001 From: keshav-005 Date: Wed, 22 Apr 2026 21:17:57 +0530 Subject: [PATCH 1/4] 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 +}; From 0440110da98d10d5b9be5ee186f1145ad03a5a87 Mon Sep 17 00:00:00 2001 From: keshav-005 Date: Wed, 22 Apr 2026 23:07:48 +0530 Subject: [PATCH 2/4] Address password reset review feedback --- app/(auth)/forgot-password/page.tsx | 22 ++-- .../reset-password/ResetPasswordForm.tsx | 117 ++++++++++++++++++ app/(auth)/reset-password/page.tsx | 107 +--------------- lib/actions/auth.actions.ts | 13 +- lib/nodemailer/reset-password.ts | 16 ++- 5 files changed, 161 insertions(+), 114 deletions(-) create mode 100644 app/(auth)/reset-password/ResetPasswordForm.tsx diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx index b3af01f..1c0f747 100644 --- a/app/(auth)/forgot-password/page.tsx +++ b/app/(auth)/forgot-password/page.tsx @@ -26,16 +26,22 @@ const ForgotPasswordPage = () => { }); const onSubmit = async (data: ForgotPasswordFormData) => { - const result = await requestPasswordResetEmail(data); + try { + const result = await requestPasswordResetEmail(data); - if (result.success) { - toast.success('If an account exists for that email, a reset link has been sent.'); - return; + 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.', + }); + } catch (error) { + toast.error('Password reset unavailable', { + description: error instanceof Error ? error.message : 'Unable to start password reset.', + }); } - - toast.error('Password reset unavailable', { - description: result.error ?? 'Unable to start password reset.', - }); }; return ( diff --git a/app/(auth)/reset-password/ResetPasswordForm.tsx b/app/(auth)/reset-password/ResetPasswordForm.tsx new file mode 100644 index 0000000..8cb0979 --- /dev/null +++ b/app/(auth)/reset-password/ResetPasswordForm.tsx @@ -0,0 +1,117 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; + +import FooterLink from '@/components/forms/FooterLink'; +import InputField from '@/components/forms/InputField'; +import OpenDevSocietyBranding from '@/components/OpenDevSocietyBranding'; +import { Button } from '@/components/ui/button'; +import { resetPasswordWithToken } from '@/lib/actions/auth.actions'; + +type ResetPasswordFormData = { + newPassword: string; + confirmPassword: string; +}; + +const ResetPasswordForm = () => { + 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; + } + + try { + 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.', + }); + } catch (error) { + toast.error('Password reset failed', { + description: error instanceof Error ? error.message : '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 ResetPasswordForm; diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 4a95672..5951267 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -1,109 +1,12 @@ -'use client'; +import { Suspense } from 'react'; -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; -}; +import ResetPasswordForm from './ResetPasswordForm'; 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', - }} - /> - - - - - - - + Loading reset form...}> + + ); }; diff --git a/lib/actions/auth.actions.ts b/lib/actions/auth.actions.ts index 4750a1b..220c831 100644 --- a/lib/actions/auth.actions.ts +++ b/lib/actions/auth.actions.ts @@ -64,7 +64,18 @@ export const requestPasswordResetEmail = async ({ email }: { email: string }) => } try { - const baseUrl = process.env.BETTER_AUTH_URL ?? 'http://localhost:3000'; + const configuredBaseUrl = process.env.BETTER_AUTH_URL; + const baseUrl = configuredBaseUrl || ( + process.env.NODE_ENV !== 'production' ? 'http://localhost:3000' : null + ); + + if (!baseUrl) { + return { + success: false, + error: 'BETTER_AUTH_URL must be configured before password reset emails can be sent.', + } + } + await auth.api.requestPasswordReset({ body: { email, diff --git a/lib/nodemailer/reset-password.ts b/lib/nodemailer/reset-password.ts index 5255e22..309cdb1 100644 --- a/lib/nodemailer/reset-password.ts +++ b/lib/nodemailer/reset-password.ts @@ -1,5 +1,13 @@ import { transporter } from "@/lib/nodemailer"; +const escapeHtml = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + export const sendPasswordResetEmail = async ( { email, name, resetUrl }: { email: string; name?: string | null; resetUrl: string } ) => { @@ -9,16 +17,18 @@ export const sendPasswordResetEmail = async ( } const firstName = name?.trim().split(' ')[0] || 'there'; + const escapedFirstName = escapeHtml(firstName); + const escapedResetUrl = escapeHtml(encodeURI(resetUrl)); const html = `

Reset your password

-

Hi ${firstName},

+

Hi ${escapedFirstName},

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

Reset password @@ -34,7 +44,7 @@ export const sendPasswordResetEmail = async ( from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`, to: email, subject: 'Reset your Openstock password', - text: `Reset your password: ${resetUrl}`, + text: `Reset your password: ${encodeURI(resetUrl)}`, html, }); From 63625261686463ca3a0b871721fbf0a65142b5f2 Mon Sep 17 00:00:00 2001 From: keshav-005 Date: Wed, 22 Apr 2026 23:16:01 +0530 Subject: [PATCH 3/4] Add password reset email tests --- __tests__/reset-password-email.test.ts | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 __tests__/reset-password-email.test.ts diff --git a/__tests__/reset-password-email.test.ts b/__tests__/reset-password-email.test.ts new file mode 100644 index 0000000..323bd48 --- /dev/null +++ b/__tests__/reset-password-email.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/nodemailer', () => ({ + transporter: { + sendMail: vi.fn(), + }, +})); + +import { transporter } from '@/lib/nodemailer'; +import { sendPasswordResetEmail } from '@/lib/nodemailer/reset-password'; + +describe('sendPasswordResetEmail', () => { + const originalEnv = { ...process.env }; + const sendMailMock = vi.mocked(transporter.sendMail); + + beforeEach(() => { + process.env = { + ...originalEnv, + NODEMAILER_EMAIL: 'sender@example.com', + NODEMAILER_PASSWORD: 'secret', + }; + sendMailMock.mockReset(); + sendMailMock.mockResolvedValue({ messageId: 'msg-123' } as never); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('escapes interpolated values before building the HTML email', async () => { + await sendPasswordResetEmail({ + email: 'user@example.com', + name: '', + resetUrl: 'https://example.com/reset-password?token=a b&next=