From 59cfcc7be4a0908053a7ca96a4c4d68097477d73 Mon Sep 17 00:00:00 2001 From: mARTin Date: Tue, 10 Mar 2026 16:11:30 +0100 Subject: [PATCH] feat: implement password reset flow with email - API now handles two cases: send reset link (email only) and reset password (token + newPassword) - Generates UUID token stored on user with 1-hour expiry - Sends styled HTML email with reset link via SMTP - Prevents email enumeration (always returns success) v1.23.0 --- package.json | 2 +- src/app/api/auth/reset-password/route.ts | 92 ++++++++++++++++++------ src/lib/email.ts | 82 +++++++++++++++++++++ 3 files changed, 153 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index b47c954..f4e0d95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.22.3", + "version": "1.23.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index c2900cd..f454ded 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -1,32 +1,80 @@ import { NextRequest, NextResponse } from 'next/server'; import { hash } from 'bcryptjs'; +import { prisma } from '@/lib/prisma'; +import { sendPasswordResetEmail } from '@/lib/email'; +import crypto from 'crypto'; export async function POST(request: NextRequest) { try { - const { email, token, newPassword } = await request.json(); + const body = await request.json(); + const { email, token, newPassword } = body; - // Basic validation - if (!email || !token || !newPassword) { - return NextResponse.json( - { error: 'Email, token, and new password are required' }, - { status: 400 } - ); + // CASE 1: Send reset link (only email provided) + if (email && !token && !newPassword) { + const user = await prisma.user.findUnique({ where: { email } }); + + // Always return success to prevent email enumeration + if (!user) { + return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' }); + } + + const resetToken = crypto.randomUUID(); + const resetExpires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await prisma.user.update({ + where: { email }, + data: { + passwordResetToken: resetToken, + passwordResetExpires: resetExpires, + }, + }); + + await sendPasswordResetEmail(email, resetToken); + + return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' }); } - // In a real app, you would: - // 1. Look up user by email - // 2. Verify the token against stored token and expiration - // 3. Hash the new password - // 4. Update the user's password in database - // 5. Clear the reset token - console.log('Resetting password for email:', email); - - // Simulate database operations - // For now, we'll just validate that the token would be valid and hash the password - const hashedPassword = await hash(newPassword, 10); - - // Mock successful reset - return NextResponse.json({ message: 'Password reset successfully' }); + // CASE 2: Reset password (token + newPassword provided) + if (token && newPassword) { + if (newPassword.length < 6) { + return NextResponse.json( + { error: 'Password must be at least 6 characters' }, + { status: 400 } + ); + } + + const user = await (prisma.user as any).findFirst({ + where: { + passwordResetToken: token, + passwordResetExpires: { gt: new Date() }, + }, + }); + + if (!user) { + return NextResponse.json( + { error: 'Invalid or expired reset token' }, + { status: 400 } + ); + } + + const hashedPassword = await hash(newPassword, 10); + + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash: hashedPassword, + passwordResetToken: null, + passwordResetExpires: null, + }, + }); + + return NextResponse.json({ message: 'Password reset successfully' }); + } + + return NextResponse.json( + { error: 'Invalid request' }, + { status: 400 } + ); } catch (error) { console.error('Password reset error:', error); return NextResponse.json( @@ -34,4 +82,4 @@ export async function POST(request: NextRequest) { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 8bb5909..1818967 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -132,6 +132,88 @@ export async function sendVerificationEmail( } } +export async function sendPasswordResetEmail(email: string, token: string) { + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + const resetLink = `${baseUrl}/auth/reset-password?token=${token}`; + + const html = ` + + + + + + + + + + + +
+ + + + + + + + + + +
+

+ My Weekly ToDo's +

+
+

+ We received a request to reset your password. Click the button below to choose a new one. +

+ + + + +
+ + Reset Password + +
+

+ This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email. +

+
+

+ Simple. Beautiful. Yours. +

+
+
+ + + `; + + const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" '; + console.log(`[EMAIL] Sending password reset email to ${email} from ${from}`); + console.log(`\n======================================================`); + console.log(`[DEV RESET LINK]:\n${resetLink}`); + console.log(`======================================================\n`); + + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('SMTP timeout')), 15000) + ); + + const mailPromise = getTransporter().sendMail({ + from, + to: email, + subject: `Password Reset – My Weekly ToDo's`, + html, + }); + + const result = await Promise.race([mailPromise, timeoutPromise]) as any; + console.log(`[EMAIL] Password reset email sent successfully: ${result.messageId}`); + } catch (err: any) { + console.error(`[EMAIL] Failed to send reset email (Error: ${err.message}). The reset link is printed above for manual use.`); + } +} + export function generateVerificationCode(): string { return Math.floor(100000 + Math.random() * 900000).toString(); }