Add password reset flow
This commit is contained in:
parent
fe59884827
commit
0b40dcdb14
|
|
@ -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<ForgotPasswordFormData>({
|
||||
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 (
|
||||
<>
|
||||
<h1 className="form-title">Forgot your password?</h1>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
Enter your email address and we'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;
|
||||
|
|
@ -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<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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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 ResetPasswordPage;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
|
||||
|
|
@ -38,4 +49,4 @@ export const getAuth = async () => {
|
|||
return authInstance;
|
||||
}
|
||||
|
||||
export const auth = await getAuth();
|
||||
export const auth = await getAuth();
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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 ${firstName},</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="${resetUrl}"
|
||||
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: ${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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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).*)',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue