Address password reset review feedback
This commit is contained in:
parent
0b40dcdb14
commit
0440110da9
|
|
@ -26,6 +26,7 @@ const ForgotPasswordPage = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: ForgotPasswordFormData) => {
|
const onSubmit = async (data: ForgotPasswordFormData) => {
|
||||||
|
try {
|
||||||
const result = await requestPasswordResetEmail(data);
|
const result = await requestPasswordResetEmail(data);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -36,6 +37,11 @@ const ForgotPasswordPage = () => {
|
||||||
toast.error('Password reset unavailable', {
|
toast.error('Password reset unavailable', {
|
||||||
description: result.error ?? 'Unable to start password reset.',
|
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 (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,109 +1,12 @@
|
||||||
'use client';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import ResetPasswordForm from './ResetPasswordForm';
|
||||||
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 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 (
|
return (
|
||||||
<>
|
<Suspense fallback={<div className="text-sm text-gray-400">Loading reset form...</div>}>
|
||||||
<h1 className="form-title">Choose a new password</h1>
|
<ResetPasswordForm />
|
||||||
<p className="text-sm text-gray-400 mb-6">
|
</Suspense>
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,18 @@ export const requestPasswordResetEmail = async ({ email }: { email: string }) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
await auth.api.requestPasswordReset({
|
||||||
body: {
|
body: {
|
||||||
email,
|
email,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { transporter } from "@/lib/nodemailer";
|
import { transporter } from "@/lib/nodemailer";
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
export const sendPasswordResetEmail = async (
|
export const sendPasswordResetEmail = async (
|
||||||
{ email, name, resetUrl }: { email: string; name?: string | null; resetUrl: string }
|
{ 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 firstName = name?.trim().split(' ')[0] || 'there';
|
||||||
|
const escapedFirstName = escapeHtml(firstName);
|
||||||
|
const escapedResetUrl = escapeHtml(encodeURI(resetUrl));
|
||||||
const html = `
|
const html = `
|
||||||
<div style="background:#000;padding:32px;font-family:Arial,sans-serif;color:#fff;">
|
<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;">
|
<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>
|
<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 16px;color:#d4d4d8;">Hi ${escapedFirstName},</p>
|
||||||
<p style="margin:0 0 24px;color:#d4d4d8;line-height:1.6;">
|
<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.
|
We received a request to reset your Openstock password. Use the button below to choose a new one.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="${resetUrl}"
|
href="${escapedResetUrl}"
|
||||||
style="display:inline-block;background:#facc15;color:#111827;padding:12px 20px;border-radius:9999px;text-decoration:none;font-weight:700;"
|
style="display:inline-block;background:#facc15;color:#111827;padding:12px 20px;border-radius:9999px;text-decoration:none;font-weight:700;"
|
||||||
>
|
>
|
||||||
Reset password
|
Reset password
|
||||||
|
|
@ -34,7 +44,7 @@ export const sendPasswordResetEmail = async (
|
||||||
from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`,
|
from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`,
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Reset your Openstock password',
|
subject: 'Reset your Openstock password',
|
||||||
text: `Reset your password: ${resetUrl}`,
|
text: `Reset your password: ${encodeURI(resetUrl)}`,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue