From 424c61a176ff85a26487a2815ea670a721556c57 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Tue, 18 Nov 2025 19:21:29 +0100 Subject: [PATCH] Forgot password implementierung --- .claude/settings.local.json | 3 +- package-lock.json | 98 +++++++++- package.json | 1 + prisma/schema.prisma | 4 + src/app/(app)/qr/[id]/edit/page.tsx | 10 +- src/app/(auth)/forgot-password/page.tsx | 155 ++++++++++++++++ src/app/(auth)/reset-password/page.tsx | 208 ++++++++++++++++++++++ src/app/api/auth/forgot-password/route.ts | 89 +++++++++ src/app/api/auth/reset-password/route.ts | 90 ++++++++++ src/app/not-found.tsx | 26 +-- src/lib/email.ts | 104 +++++++++++ 11 files changed, 758 insertions(+), 30 deletions(-) create mode 100644 src/app/(auth)/forgot-password/page.tsx create mode 100644 src/app/(auth)/reset-password/page.tsx create mode 100644 src/app/api/auth/forgot-password/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts create mode 100644 src/lib/email.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b17bb2d..ddc582c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,8 @@ "Bash(npm run build:*)", "Bash(ls:*)", "Bash(curl:*)", - "Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")" + "Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")", + "Bash(pkill:*)" ], "deny": [], "ask": [] diff --git a/package-lock.json b/package-lock.json index f5b3180..d6ce0a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-i18next": "^13.5.0", + "resend": "^6.4.2", "sharp": "^0.33.1", "stripe": "^19.1.0", "tailwind-merge": "^2.2.0", @@ -1508,6 +1509,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@stripe/stripe-js": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.3.0.tgz", @@ -3441,6 +3448,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4005,6 +4018,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -6471,6 +6490,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6690,6 +6715,32 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resend": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.4.2.tgz", + "integrity": "sha512-YnxmwneltZtjc7Xff+8ZjG1/xPLdstCiqsedgO/JxWTf7vKRAPCx6CkhQ3ZXskG0mrmf8+I5wr/wNRd8PQMUfw==", + "license": "MIT", + "dependencies": { + "svix": "1.76.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7507,6 +7558,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.76.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.76.1.tgz", + "integrity": "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "@types/node": "^22.7.5", + "es6-promise": "^4.2.8", + "fast-sha256": "^1.3.0", + "url-parse": "^1.5.10", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/@types/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -7838,7 +7925,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -7917,6 +8003,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 442cce2..29658b1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-i18next": "^13.5.0", + "resend": "^6.4.2", "sharp": "^0.33.1", "stripe": "^19.1.0", "tailwind-merge": "^2.2.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cd41799..f4b5156 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,10 @@ model User { stripeCurrentPeriodEnd DateTime? plan Plan @default(FREE) + // Password reset fields + resetPasswordToken String? @unique + resetPasswordExpires DateTime? + qrCodes QRCode[] integrations Integration[] accounts Account[] diff --git a/src/app/(app)/qr/[id]/edit/page.tsx b/src/app/(app)/qr/[id]/edit/page.tsx index 8c99278..7b5c7fe 100644 --- a/src/app/(app)/qr/[id]/edit/page.tsx +++ b/src/app/(app)/qr/[id]/edit/page.tsx @@ -6,11 +6,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { showToast } from '@/components/ui/Toast'; +import { useCsrf } from '@/hooks/useCsrf'; export default function EditQRPage() { const router = useRouter(); const params = useParams(); const qrId = params.id as string; + const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -47,11 +49,8 @@ export default function EditQRPage() { setSaving(true); try { - const response = await fetch(`/api/qrs/${qrId}`, { + const response = await fetchWithCsrf(`/api/qrs/${qrId}`, { method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, body: JSON.stringify({ title, content, @@ -253,8 +252,9 @@ export default function EditQRPage() { diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..8aebe63 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,155 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/Card'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { useCsrf } from '@/hooks/useCsrf'; + +export default function ForgotPasswordPage() { + const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const response = await fetchWithCsrf('/api/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }); + + const data = await response.json(); + + if (response.ok) { + setSuccess(true); + } else { + setError(data.error || 'Failed to send reset email'); + } + } catch (err) { + setError('An error occurred. Please try again.'); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+
+
+ + QR Master + QR Master + +

Check Your Email

+

We've sent you a password reset link

+
+ + + +
+
+ + + +
+ +

+ We've sent a password reset link to {email} +

+ +

+ Please check your email and click the link to reset your password. The link will expire in 1 hour. +

+ +
+ + + + + +
+
+
+
+
+
+ ); + } + + return ( +
+
+
+ + QR Master + QR Master + +

Forgot Password?

+

No worries, we'll send you reset instructions

+
+ + + +
+ {error && ( +
+ {error} +
+ )} + + setEmail(e.target.value)} + placeholder="you@example.com" + required + disabled={loading || csrfLoading} + /> + + + +
+ + ← Back to Login + +
+
+
+
+ +

+ Remember your password?{' '} + + Sign in + +

+
+
+ ); +} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..4be1774 --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,208 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { Card, CardContent } from '@/components/ui/Card'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { useCsrf } from '@/hooks/useCsrf'; + +export default function ResetPasswordPage() { + const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const [token, setToken] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + const tokenParam = searchParams.get('token'); + if (!tokenParam) { + setError('Invalid or missing reset token. Please request a new password reset link.'); + } else { + setToken(tokenParam); + } + }, [searchParams]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + // Validate passwords match + if (password !== confirmPassword) { + setError('Passwords do not match'); + setLoading(false); + return; + } + + // Validate password length + if (password.length < 8) { + setError('Password must be at least 8 characters long'); + setLoading(false); + return; + } + + try { + const response = await fetchWithCsrf('/api/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ token, password }), + }); + + const data = await response.json(); + + if (response.ok) { + setSuccess(true); + // Redirect to login after 3 seconds + setTimeout(() => { + router.push('/login'); + }, 3000); + } else { + setError(data.error || 'Failed to reset password'); + } + } catch (err) { + setError('An error occurred. Please try again.'); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+
+
+ + QR Master + QR Master + +

Password Reset Successful

+

Your password has been updated

+
+ + + +
+
+ + + +
+ +

+ Your password has been successfully reset! +

+ +

+ Redirecting you to the login page in 3 seconds... +

+ + + + +
+
+
+
+
+ ); + } + + return ( +
+
+
+ + QR Master + QR Master + +

Reset Your Password

+

Enter your new password below

+
+ + + + {!token ? ( +
+
+ + + +
+

{error}

+ + + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + + setPassword(e.target.value)} + placeholder="Enter new password" + required + disabled={loading || csrfLoading} + minLength={8} + /> + + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + disabled={loading || csrfLoading} + minLength={8} + /> + +
+ Password must be at least 8 characters long +
+ + + +
+ + ← Back to Login + +
+
+ )} +
+
+ +

+ Remember your password?{' '} + + Sign in + +

+
+
+ ); +} diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..1e8a3dc --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { csrfProtection } from '@/lib/csrf'; +import { sendPasswordResetEmail } from '@/lib/email'; +import crypto from 'crypto'; + +export async function POST(req: NextRequest) { + try { + // Verify CSRF token + const csrfCheck = csrfProtection(req); + if (!csrfCheck.valid) { + return NextResponse.json( + { error: csrfCheck.error || 'Invalid CSRF token' }, + { status: 403 } + ); + } + + const body = await req.json(); + const { email } = body; + + if (!email) { + return NextResponse.json( + { error: 'Email is required' }, + { status: 400 } + ); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: 'Invalid email format' }, + { status: 400 } + ); + } + + // Find user by email + const user = await db.user.findUnique({ + where: { email: email.toLowerCase() }, + }); + + // For security, always return success even if email doesn't exist + // This prevents email enumeration attacks + if (!user) { + console.log('Password reset requested for non-existent email:', email); + return NextResponse.json( + { message: 'If an account with that email exists, a password reset link has been sent.' }, + { status: 200 } + ); + } + + // Generate secure random token + const resetToken = crypto.randomBytes(32).toString('hex'); + + // Set token expiration to 1 hour from now + const resetExpires = new Date(Date.now() + 3600000); // 1 hour + + // Save token and expiration to database + await db.user.update({ + where: { id: user.id }, + data: { + resetPasswordToken: resetToken, + resetPasswordExpires: resetExpires, + }, + }); + + // Send password reset email + try { + await sendPasswordResetEmail(email, resetToken); + } catch (emailError) { + console.error('Error sending password reset email:', emailError); + return NextResponse.json( + { error: 'Failed to send reset email. Please try again later.' }, + { status: 500 } + ); + } + + return NextResponse.json( + { message: 'Password reset email sent successfully' }, + { status: 200 } + ); + } catch (error) { + console.error('Error in forgot-password route:', error); + return NextResponse.json( + { error: 'An error occurred. Please try again.' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..800caca --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { csrfProtection } from '@/lib/csrf'; +import bcrypt from 'bcryptjs'; + +export async function POST(req: NextRequest) { + try { + // Verify CSRF token + const csrfCheck = csrfProtection(req); + if (!csrfCheck.valid) { + return NextResponse.json( + { error: csrfCheck.error || 'Invalid CSRF token' }, + { status: 403 } + ); + } + + const body = await req.json(); + const { token, password } = body; + + if (!token || !password) { + return NextResponse.json( + { error: 'Token and password are required' }, + { status: 400 } + ); + } + + // Validate password length + if (password.length < 8) { + return NextResponse.json( + { error: 'Password must be at least 8 characters long' }, + { status: 400 } + ); + } + + // Find user with this reset token + const user = await db.user.findUnique({ + where: { resetPasswordToken: token }, + }); + + if (!user) { + return NextResponse.json( + { error: 'Invalid or expired reset token' }, + { status: 400 } + ); + } + + // Check if token has expired + if (!user.resetPasswordExpires || user.resetPasswordExpires < new Date()) { + // Clear expired token + await db.user.update({ + where: { id: user.id }, + data: { + resetPasswordToken: null, + resetPasswordExpires: null, + }, + }); + + return NextResponse.json( + { error: 'Reset token has expired. Please request a new password reset link.' }, + { status: 400 } + ); + } + + // Hash the new password + const hashedPassword = await bcrypt.hash(password, 10); + + // Update user's password and clear reset token + await db.user.update({ + where: { id: user.id }, + data: { + password: hashedPassword, + resetPasswordToken: null, + resetPasswordExpires: null, + }, + }); + + console.log('Password successfully reset for user:', user.email); + + return NextResponse.json( + { message: 'Password reset successfully' }, + { status: 200 } + ); + } catch (error) { + console.error('Error in reset-password route:', error); + return NextResponse.json( + { error: 'An error occurred. Please try again.' }, + { status: 500 } + ); + } +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index eb21b57..b2e7b3f 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -35,8 +35,8 @@ export default function NotFound() {

- {/* Action Buttons */} -
+ {/* Action Button */} +
- - - - -
- - {/* Help Text */} -
-

- Need help?{' '} - - Visit our FAQ - {' '} - or{' '} - - read our blog - -

diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..ec3657a --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,104 @@ +import { Resend } from 'resend'; + +const resend = new Resend(process.env.RESEND_API_KEY); + +export async function sendPasswordResetEmail(email: string, resetToken: string) { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; + const resetUrl = `${appUrl}/reset-password?token=${resetToken}`; + + try { + await resend.emails.send({ + from: 'QR Master ', // Use Resend's testing domain + to: email, + subject: 'Reset Your Password - QR Master', + html: ` + + + + + + Reset Your Password + + + + + + +
+ + + + + + + + + + + + + + + +
+

QR Master

+
+

Reset Your Password

+ +

+ Hi there, +

+ +

+ You requested to reset your password for your QR Master account. Click the button below to choose a new password: +

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

+ Or copy and paste this link into your browser: +

+ +

+ ${resetUrl} +

+ +
+

+ This link will expire in 1 hour. +

+ +

+ If you didn't request a password reset, you can safely ignore this email. Your password will not be changed. +

+
+
+

+ © 2025 QR Master. All rights reserved. +

+

+ This is an automated email. Please do not reply. +

+
+
+ + + `, + }); + + console.log('Password reset email sent successfully to:', email); + return { success: true }; + } catch (error) { + console.error('Error sending password reset email:', error); + throw new Error('Failed to send password reset email'); + } +}