Merge pull request #71 from keshav-005/fix-66-password-reset

Add password reset flow
This commit is contained in:
Ravi 2026-05-03 01:11:43 +05:30 committed by GitHub
commit e06802738c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 400 additions and 7 deletions

View File

@ -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: '<Admin&Co.>',
resetUrl: 'https://example.com/reset-password?token=a b&next=<script>',
});
expect(sendMailMock).toHaveBeenCalledTimes(1);
const [mailOptions] = sendMailMock.mock.calls[0];
expect(mailOptions.html).toContain('Hi &lt;Admin&amp;Co.&gt;,');
expect(mailOptions.html).toContain('href="https://example.com/reset-password?token=a%20b&amp;next=%3Cscript%3E"');
expect(mailOptions.html).not.toContain('<script>');
expect(mailOptions.text).toContain('https://example.com/reset-password?token=a%20b&next=%3Cscript%3E');
});
it('throws when reset email credentials are missing', async () => {
delete process.env.NODEMAILER_EMAIL;
delete process.env.NODEMAILER_PASSWORD;
await expect(
sendPasswordResetEmail({
email: 'user@example.com',
name: 'User',
resetUrl: 'https://example.com/reset-password?token=test',
})
).rejects.toThrow('Email credentials not configured');
});
});

View File

@ -0,0 +1,81 @@
'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<ForgotPasswordFormData>({
defaultValues: {
email: '',
},
mode: 'onBlur',
});
const onSubmit = async (data: ForgotPasswordFormData) => {
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;
}
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.',
});
}
};
return (
<>
<h1 className="form-title">Forgot your password?</h1>
<p className="text-sm text-gray-400 mb-6">
Enter your email address and we&apos;ll send you a password reset link.
</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<InputField
name="email"
label="Email"
placeholder="opendevsociety@cc.cc"
register={register}
error={errors.email}
validation={{
required: 'Email is required',
pattern: {
value: /^[\w-.]+@([\w-]+\.)+[\w-]{2,}$/,
message: 'Please enter a valid email address',
},
}}
/>
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
{isSubmitting ? 'Sending reset link' : 'Send reset link'}
</Button>
<FooterLink text="Remembered it?" linkText="Sign in" href="/sign-in" />
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center" />
</form>
</>
);
};
export default ForgotPasswordPage;

View File

@ -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<ResetPasswordFormData>({
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 (
<>
<h1 className="form-title">Choose a new password</h1>
<p className="text-sm text-gray-400 mb-6">
Enter a new password for your account.
</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<InputField
name="newPassword"
label="New Password"
placeholder="Enter a new password"
type="password"
register={register}
error={errors.newPassword}
validation={{ required: 'New password is required', minLength: 8 }}
/>
<InputField
name="confirmPassword"
label="Confirm Password"
placeholder="Confirm your new password"
type="password"
register={register}
error={errors.confirmPassword}
validation={{
required: 'Please confirm your new password',
validate: (value: string) =>
value === newPassword || 'Passwords do not match',
}}
/>
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
{isSubmitting ? 'Resetting password' : 'Reset password'}
</Button>
<FooterLink text="Need a fresh link?" linkText="Request another one" href="/forgot-password" />
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center" />
</form>
</>
);
};
export default ResetPasswordForm;

View File

@ -0,0 +1,13 @@
import { Suspense } from 'react';
import ResetPasswordForm from './ResetPasswordForm';
const ResetPasswordPage = () => {
return (
<Suspense fallback={<div className="text-sm text-gray-400">Loading reset form...</div>}>
<ResetPasswordForm />
</Suspense>
);
};
export default ResetPasswordPage;

View File

@ -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 }}
/>
<div className="flex justify-end">
<Link href="/forgot-password" className="footer-link text-sm">
Forgot password?
</Link>
</div>
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
{isSubmitting ? 'Signing In' : 'Sign In'}
</Button>

View File

@ -58,6 +58,56 @@ 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 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,
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() });

View File

@ -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<typeof betterAuth> | 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()],

View File

@ -0,0 +1,57 @@
import { transporter } from "@/lib/nodemailer";
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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 escapedFirstName = escapeHtml(firstName);
const escapedResetUrl = escapeHtml(encodeURI(resetUrl));
const html = `
<div style="background:#000;padding:32px;font-family:Arial,sans-serif;color:#fff;">
<div style="max-width:560px;margin:0 auto;border:1px solid #333;border-radius:12px;padding:32px;background:#111;">
<h1 style="margin:0 0 16px;font-size:28px;">Reset your password</h1>
<p style="margin:0 0 16px;color:#d4d4d8;">Hi ${escapedFirstName},</p>
<p style="margin:0 0 24px;color:#d4d4d8;line-height:1.6;">
We received a request to reset your Openstock password. Use the button below to choose a new one.
</p>
<a
href="${escapedResetUrl}"
style="display:inline-block;background:#facc15;color:#111827;padding:12px 20px;border-radius:9999px;text-decoration:none;font-weight:700;"
>
Reset password
</a>
<p style="margin:24px 0 0;color:#a1a1aa;line-height:1.6;">
If you did not request this, you can safely ignore this email.
</p>
</div>
</div>
`;
const info = await transporter.sendMail({
from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`,
to: email,
subject: 'Reset your Openstock password',
text: `Reset your password: ${encodeURI(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;
}
};

View File

@ -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).*)',
],
};