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
This commit is contained in:
mARTin 2026-03-10 16:11:30 +01:00
parent 704738777a
commit 59cfcc7be4
3 changed files with 153 additions and 23 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -1,32 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { hash } from 'bcryptjs'; import { hash } from 'bcryptjs';
import { prisma } from '@/lib/prisma';
import { sendPasswordResetEmail } from '@/lib/email';
import crypto from 'crypto';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { email, token, newPassword } = await request.json(); const body = await request.json();
const { email, token, newPassword } = body;
// Basic validation // CASE 1: Send reset link (only email provided)
if (!email || !token || !newPassword) { 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.' });
}
// CASE 2: Reset password (token + newPassword provided)
if (token && newPassword) {
if (newPassword.length < 6) {
return NextResponse.json( return NextResponse.json(
{ error: 'Email, token, and new password are required' }, { error: 'Password must be at least 6 characters' },
{ status: 400 } { status: 400 }
); );
} }
// In a real app, you would: const user = await (prisma.user as any).findFirst({
// 1. Look up user by email where: {
// 2. Verify the token against stored token and expiration passwordResetToken: token,
// 3. Hash the new password passwordResetExpires: { gt: new Date() },
// 4. Update the user's password in database },
// 5. Clear the reset token });
console.log('Resetting password for email:', email);
if (!user) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// 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); const hashedPassword = await hash(newPassword, 10);
// Mock successful reset 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({ message: 'Password reset successfully' });
}
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400 }
);
} catch (error) { } catch (error) {
console.error('Password reset error:', error); console.error('Password reset error:', error);
return NextResponse.json( return NextResponse.json(

View File

@ -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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f7f9fc; font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f7f9fc; padding: 40px 20px;">
<tr>
<td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 32px 40px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
My Weekly ToDo's
</h1>
</td>
</tr>
<tr>
<td style="padding: 16px 40px;">
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
We received a request to reset your password. Click the button below to choose a new one.
</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 0 0 24px;">
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
Reset Password
</a>
</td>
</tr>
</table>
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
Simple. Beautiful. Yours.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
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 { export function generateVerificationCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString(); return Math.floor(100000 + Math.random() * 900000).toString();
} }