Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
684 changes: 542 additions & 142 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ model User {
updatedAt DateTime @updatedAt
}

model PasswordResetToken {
id String @id @default(cuid())
email String
token String @unique
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
}

model Session {
id String @id @default(uuid())
userId String
Expand Down
97 changes: 97 additions & 0 deletions src/app/api/auth/password-reset/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// api/auth/password-reset/confirm/route.ts

import { NextResponse } from "next/server";
import { z } from "zod";
import crypto from 'crypto';
import prisma from "@/lib/db";
import bcrypt from 'bcryptjs';

const confirmSchema = z.object({
token: z.string(),
password: z.string().min(8)
})

export async function POST(req: Request){
try {
const body = await req.json();

// validate input
const validation = confirmSchema.safeParse(body);

if (!validation.success) {
return NextResponse.json(
{error: 'Invalid input', details: validation.error.errors},
{status: 400}
)
}

const { token, password } = validation.data;

// hash the token to match the hashed token in the database
const hashedToken = await crypto.createHash('sha256').update(token).digest('hex');

// find the reset token
const resetToken = await prisma.passwordResetToken.findUnique({
where: {token: hashedToken}
})

if (!resetToken) {
return NextResponse.json(
// Delete expired token
{error: 'Invalid or expired reset token'},
{status: 400}
)
}

// check if the token is already used
if (resetToken.used) {
return NextResponse.json(
{error: 'Reset token already used'},
{status: 400}
)
}

// find the user
const user = await prisma.user.findUnique({
where: {email: resetToken.email},
})

if (!user) {
return NextResponse.json(
{error: 'User not found'},
{status: 404}
)
}

// hash the new password
const hashedPassword = await bcrypt.hash(password, 10);

// update user's password and mark token as used
await prisma.$transaction([
prisma.user.update({
where: {id: user.id},
data: {password: hashedPassword},
}),
prisma.passwordResetToken.update({
where: {id: resetToken.id},
data: {used: true},
}),
]);

// Delete all sessions for this user to force re-login
await prisma.session.deleteMany({
where: {userId: user.id},
});

return NextResponse.json(
{message: 'Password reset successful'},
{status: 200}
);
} catch (error) {
console.error('Password reset confirmation error:', error);
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
)
}
}
77 changes: 77 additions & 0 deletions src/app/api/auth/password-reset/request/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/db";
import { z } from "zod";
import crypto from 'crypto';

const requestSchema = z.object({
email: z.string().email()
})


export async function POST(req: Request) {
try {
const body = await req.json();

// validate input
const validation = requestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{error: 'Invalid email', details: validation.error.errors},
{status: 400}
);
}

const { email } = validation.data;

// Check if user exists
const user = await prisma.user.findUnique({
where: { email }
});

// Always return success even if user doesn't exist (security best practice)
if (!user) {
return NextResponse.json(
{ message: 'If an account with that email exists, you will receive a password reset link.' },
{ status: 200 }
);
}

// generate reset token
const resetToken = crypto.randomBytes(32).toString('hex');
const hashedToken = await crypto.createHash('sha256').update(resetToken).digest('hex');

// set expiration time (1 hour from now)
const expiresAt = new Date(Date.now() + 3600000);

// Delete any existing reset tokens for this email
await prisma.passwordResetToken.deleteMany({
where: { email }
});

// Create new reset token
await prisma.passwordResetToken.create({
data: {
email,
token: hashedToken,
expiresAt,
},
});

console.log('Reset token created for email:', email);
console.log('Reset URL:', `${req.headers.get('origin')}/reset-password?token=${resetToken}`);

return NextResponse.json(
{message: 'If an account with that email exists, you will receive a password reset link.'},
{status: 200}
);

} catch (error) {
console.error('Password reset request error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}


5 changes: 5 additions & 0 deletions src/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ForgotPasswordForm from "@/components/ForgotPasswordForm";

export default function ForgotPasswordPage () {
return <ForgotPasswordForm />;
}
5 changes: 5 additions & 0 deletions src/app/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ResetPasswordForm from "@/components/ResetPasswordForm";

export default function ResetPasswordPage() {
return <ResetPasswordForm />
}
120 changes: 120 additions & 0 deletions src/components/ForgotPasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// src/components/ForgotPasswordForm.tsx
'use client'

import { CheckCircle, X } from "lucide-react";
import Link from "next/link"
import { useState } from "react";

export default function ForgotPasswordForm() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');

if (!email.trim()) {
setError('Email is required');
return;
}

if (!/\S+@\S+\.\S+/.test(email)) {
setError('Please enter a valid email address');
return;
}

setIsLoading(true);

try {

// Log the request details
const requestBody = JSON.stringify({email});

const response = await fetch('/api/auth/password-reset/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
}).catch(fetchError => {
console.error('Fetch error:', fetchError);
throw new Error('Network error occurred');
});

const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send reset email');
}

setSuccess('If an account with that email exists, you will receive a password reset link.');
setEmail('');
} catch (error) {
console.error('Password reset request error:', error);
setError(error instanceof Error ? error.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Forgot Password</h2>
<p className="mt-2 text-center text-sm text-gray-600">Enter your email address and we will send you a link to reset your password.</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded relative" role="alert">
<button
onClick={() => setError('')}
className="absolute right-2 top-2 text-red-700 hover:text-red-900"
>
<X size={16} />
</button>
<p>{error}</p>
</div>
)}

{success && (
<div className="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded relative" role="alert">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400" />
<p className="ml-3">{success}</p>
</div>
</div>
)}
<div>
<label htmlFor="email" className="sr-only">Email address</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
placeholder="Email address"
className="appearance-none rounded-none relative-block block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
/>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50">
{isLoading ? 'Sending...' : 'Send Reset Link'}
</button>
</div>
<div className="text-sm text-center">
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
Back to login
</Link>
</div>
</form>
</div>
</div>
)
}
8 changes: 8 additions & 0 deletions src/components/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ export default function LoginForm() {
{loading ? 'Signing in ...' : 'Sign in'}
</button>
</div>
<div className="text-sm text-center">
<Link
href="/forgot-password"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Forgot your password?
</Link>
</div>
<div className="text-sm text-center">
<Link href="/register" className="font-medium text-indigo-600 hover:text-indigo-500">
Don&apos;t have an account? Sign up
Expand Down
10 changes: 10 additions & 0 deletions src/components/RegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ export default function RegisterForm () {
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Create your account</h2>
<div className="mt-4 text-center text-sm text-gray-600">
<p className="mb-2">Please follow these requirements:</p>
<ul className="list-disc list-inside space-y-1">
<li>Email must be a valid email address</li>
<li>Password must be at least 8 characters long</li>
<li>Password must contain at least one uppercase letter</li>
<li>Password must contain at least one lowercase letter</li>
<li>Password must contain at least one number</li>
</ul>
</div>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{errors.general && (
Expand Down
Loading