Skip to content

Commit e068027

Browse files
Merge pull request #71 from keshav-005/fix-66-password-reset
Add password reset flow
2 parents b3406bb + 50b999d commit e068027

9 files changed

Lines changed: 400 additions & 7 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
vi.mock('@/lib/nodemailer', () => ({
4+
transporter: {
5+
sendMail: vi.fn(),
6+
},
7+
}));
8+
9+
import { transporter } from '@/lib/nodemailer';
10+
import { sendPasswordResetEmail } from '@/lib/nodemailer/reset-password';
11+
12+
describe('sendPasswordResetEmail', () => {
13+
const originalEnv = { ...process.env };
14+
const sendMailMock = vi.mocked(transporter.sendMail);
15+
16+
beforeEach(() => {
17+
process.env = {
18+
...originalEnv,
19+
NODEMAILER_EMAIL: 'sender@example.com',
20+
NODEMAILER_PASSWORD: 'secret',
21+
};
22+
sendMailMock.mockReset();
23+
sendMailMock.mockResolvedValue({ messageId: 'msg-123' } as never);
24+
});
25+
26+
afterEach(() => {
27+
process.env = { ...originalEnv };
28+
});
29+
30+
it('escapes interpolated values before building the HTML email', async () => {
31+
await sendPasswordResetEmail({
32+
email: 'user@example.com',
33+
name: '<Admin&Co.>',
34+
resetUrl: 'https://example.com/reset-password?token=a b&next=<script>',
35+
});
36+
37+
expect(sendMailMock).toHaveBeenCalledTimes(1);
38+
const [mailOptions] = sendMailMock.mock.calls[0];
39+
40+
expect(mailOptions.html).toContain('Hi &lt;Admin&amp;Co.&gt;,');
41+
expect(mailOptions.html).toContain('href="https://example.com/reset-password?token=a%20b&amp;next=%3Cscript%3E"');
42+
expect(mailOptions.html).not.toContain('<script>');
43+
expect(mailOptions.text).toContain('https://example.com/reset-password?token=a%20b&next=%3Cscript%3E');
44+
});
45+
46+
it('throws when reset email credentials are missing', async () => {
47+
delete process.env.NODEMAILER_EMAIL;
48+
delete process.env.NODEMAILER_PASSWORD;
49+
50+
await expect(
51+
sendPasswordResetEmail({
52+
email: 'user@example.com',
53+
name: 'User',
54+
resetUrl: 'https://example.com/reset-password?token=test',
55+
})
56+
).rejects.toThrow('Email credentials not configured');
57+
});
58+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useForm } from 'react-hook-form';
5+
import { toast } from 'sonner';
6+
import { Button } from '@/components/ui/button';
7+
import InputField from '@/components/forms/InputField';
8+
import FooterLink from '@/components/forms/FooterLink';
9+
import OpenDevSocietyBranding from '@/components/OpenDevSocietyBranding';
10+
import { requestPasswordResetEmail } from '@/lib/actions/auth.actions';
11+
12+
type ForgotPasswordFormData = {
13+
email: string;
14+
};
15+
16+
const ForgotPasswordPage = () => {
17+
const {
18+
register,
19+
handleSubmit,
20+
formState: { errors, isSubmitting },
21+
} = useForm<ForgotPasswordFormData>({
22+
defaultValues: {
23+
email: '',
24+
},
25+
mode: 'onBlur',
26+
});
27+
28+
const onSubmit = async (data: ForgotPasswordFormData) => {
29+
try {
30+
const result = await requestPasswordResetEmail(data);
31+
32+
if (result.success) {
33+
toast.success('If an account exists for that email, a reset link has been sent.');
34+
return;
35+
}
36+
37+
toast.error('Password reset unavailable', {
38+
description: result.error ?? 'Unable to start password reset.',
39+
});
40+
} catch (error) {
41+
toast.error('Password reset unavailable', {
42+
description: error instanceof Error ? error.message : 'Unable to start password reset.',
43+
});
44+
}
45+
};
46+
47+
return (
48+
<>
49+
<h1 className="form-title">Forgot your password?</h1>
50+
<p className="text-sm text-gray-400 mb-6">
51+
Enter your email address and we&apos;ll send you a password reset link.
52+
</p>
53+
54+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
55+
<InputField
56+
name="email"
57+
label="Email"
58+
placeholder="opendevsociety@cc.cc"
59+
register={register}
60+
error={errors.email}
61+
validation={{
62+
required: 'Email is required',
63+
pattern: {
64+
value: /^[\w-.]+@([\w-]+\.)+[\w-]{2,}$/,
65+
message: 'Please enter a valid email address',
66+
},
67+
}}
68+
/>
69+
70+
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
71+
{isSubmitting ? 'Sending reset link' : 'Send reset link'}
72+
</Button>
73+
74+
<FooterLink text="Remembered it?" linkText="Sign in" href="/sign-in" />
75+
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center" />
76+
</form>
77+
</>
78+
);
79+
};
80+
81+
export default ForgotPasswordPage;
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
'use client';
2+
3+
import React, { useEffect } from 'react';
4+
import { useForm } from 'react-hook-form';
5+
import { useRouter, useSearchParams } from 'next/navigation';
6+
import { toast } from 'sonner';
7+
8+
import FooterLink from '@/components/forms/FooterLink';
9+
import InputField from '@/components/forms/InputField';
10+
import OpenDevSocietyBranding from '@/components/OpenDevSocietyBranding';
11+
import { Button } from '@/components/ui/button';
12+
import { resetPasswordWithToken } from '@/lib/actions/auth.actions';
13+
14+
type ResetPasswordFormData = {
15+
newPassword: string;
16+
confirmPassword: string;
17+
};
18+
19+
const ResetPasswordForm = () => {
20+
const router = useRouter();
21+
const searchParams = useSearchParams();
22+
const token = searchParams.get('token') ?? '';
23+
const error = searchParams.get('error');
24+
25+
const {
26+
register,
27+
watch,
28+
handleSubmit,
29+
formState: { errors, isSubmitting },
30+
} = useForm<ResetPasswordFormData>({
31+
defaultValues: {
32+
newPassword: '',
33+
confirmPassword: '',
34+
},
35+
mode: 'onBlur',
36+
});
37+
38+
const newPassword = watch('newPassword');
39+
40+
useEffect(() => {
41+
if (error === 'INVALID_TOKEN') {
42+
toast.error('Reset link is invalid or expired.');
43+
}
44+
}, [error]);
45+
46+
const onSubmit = async (data: ResetPasswordFormData) => {
47+
if (!token) {
48+
toast.error('Reset link is invalid or expired.');
49+
return;
50+
}
51+
52+
try {
53+
const result = await resetPasswordWithToken({
54+
token,
55+
newPassword: data.newPassword,
56+
});
57+
58+
if (result.success) {
59+
toast.success('Password updated. You can sign in now.');
60+
router.push('/sign-in');
61+
return;
62+
}
63+
64+
toast.error('Password reset failed', {
65+
description: result.error ?? 'Unable to reset your password.',
66+
});
67+
} catch (error) {
68+
toast.error('Password reset failed', {
69+
description: error instanceof Error ? error.message : 'Unable to reset your password.',
70+
});
71+
}
72+
};
73+
74+
return (
75+
<>
76+
<h1 className="form-title">Choose a new password</h1>
77+
<p className="text-sm text-gray-400 mb-6">
78+
Enter a new password for your account.
79+
</p>
80+
81+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
82+
<InputField
83+
name="newPassword"
84+
label="New Password"
85+
placeholder="Enter a new password"
86+
type="password"
87+
register={register}
88+
error={errors.newPassword}
89+
validation={{ required: 'New password is required', minLength: 8 }}
90+
/>
91+
92+
<InputField
93+
name="confirmPassword"
94+
label="Confirm Password"
95+
placeholder="Confirm your new password"
96+
type="password"
97+
register={register}
98+
error={errors.confirmPassword}
99+
validation={{
100+
required: 'Please confirm your new password',
101+
validate: (value: string) =>
102+
value === newPassword || 'Passwords do not match',
103+
}}
104+
/>
105+
106+
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
107+
{isSubmitting ? 'Resetting password' : 'Reset password'}
108+
</Button>
109+
110+
<FooterLink text="Need a fresh link?" linkText="Request another one" href="/forgot-password" />
111+
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center" />
112+
</form>
113+
</>
114+
);
115+
};
116+
117+
export default ResetPasswordForm;

app/(auth)/reset-password/page.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Suspense } from 'react';
2+
3+
import ResetPasswordForm from './ResetPasswordForm';
4+
5+
const ResetPasswordPage = () => {
6+
return (
7+
<Suspense fallback={<div className="text-sm text-gray-400">Loading reset form...</div>}>
8+
<ResetPasswordForm />
9+
</Suspense>
10+
);
11+
};
12+
13+
export default ResetPasswordPage;

app/(auth)/sign-in/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { useForm } from 'react-hook-form';
44
import { Button } from '@/components/ui/button';
55
import InputField from '@/components/forms/InputField';
66
import FooterLink from '@/components/forms/FooterLink';
7-
import { signInWithEmail, signUpWithEmail } from "@/lib/actions/auth.actions";
7+
import { signInWithEmail } from "@/lib/actions/auth.actions";
88
import { toast } from "sonner";
9-
import { signInEmail } from "better-auth/api";
9+
import Link from "next/link";
1010
import { useRouter } from "next/navigation";
1111
import OpenDevSocietyBranding from "@/components/OpenDevSocietyBranding";
1212
import React from "react";
@@ -73,6 +73,12 @@ const SignIn = () => {
7373
validation={{ required: 'Password is required', minLength: 8 }}
7474
/>
7575

76+
<div className="flex justify-end">
77+
<Link href="/forgot-password" className="footer-link text-sm">
78+
Forgot password?
79+
</Link>
80+
</div>
81+
7682
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
7783
{isSubmitting ? 'Signing In' : 'Sign In'}
7884
</Button>

lib/actions/auth.actions.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,56 @@ export const signInWithEmail = async ({ email, password }: SignInFormData) => {
5858
}
5959
}
6060

61+
export const requestPasswordResetEmail = async ({ email }: { email: string }) => {
62+
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
63+
return { success: false, error: 'Password reset email is not configured.' }
64+
}
65+
66+
try {
67+
const configuredBaseUrl = process.env.BETTER_AUTH_URL;
68+
const baseUrl = configuredBaseUrl || (
69+
process.env.NODE_ENV !== 'production' ? 'http://localhost:3000' : null
70+
);
71+
72+
if (!baseUrl) {
73+
return {
74+
success: false,
75+
error: 'BETTER_AUTH_URL must be configured before password reset emails can be sent.',
76+
}
77+
}
78+
79+
await auth.api.requestPasswordReset({
80+
body: {
81+
email,
82+
redirectTo: `${baseUrl}/reset-password`,
83+
},
84+
});
85+
86+
return { success: true }
87+
} catch (e) {
88+
console.log('Password reset request failed', e)
89+
return { success: false, error: 'Unable to send password reset email.' }
90+
}
91+
}
92+
93+
export const resetPasswordWithToken = async (
94+
{ token, newPassword }: { token: string; newPassword: string }
95+
) => {
96+
try {
97+
await auth.api.resetPassword({
98+
body: {
99+
token,
100+
newPassword,
101+
},
102+
});
103+
104+
return { success: true }
105+
} catch (e) {
106+
console.log('Password reset failed', e)
107+
return { success: false, error: 'Reset link is invalid or expired.' }
108+
}
109+
}
110+
61111
export const signOut = async () => {
62112
try {
63113
await auth.api.signOut({ headers: await headers() });

0 commit comments

Comments
 (0)