Skip to content
Open
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
Empty file.
54 changes: 54 additions & 0 deletions app/api/auth/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import crypto from "crypto";
import { sendSupportEmail } from "@/lib/email";

export async function POST(req: Request) {
try {
const body = await req.json();
const email = typeof body?.email === "string" ? body.email.toLowerCase().trim() : "";

if (!email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}

const user = await prisma.user.findUnique({ where: { email } });

if (!user) {
return NextResponse.json({ message: "If this email exists, a reset link has been sent." });
}

const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 1000 * 60 * 60); // 1 hour

await prisma.passwordResetToken.create({
data: { email, token, expires },
});

const resetLink = `${process.env.NEXTAUTH_URL}/reset-password?token=${token}`;

await sendSupportEmail({
to: email,
subject: "LinkID — Reset Your Password",
html: `
<div style="font-family: 'Segoe UI', Arial, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px; background: #fafafa; border-radius: 12px;">
<h2 style="margin: 0 0 8px; color: #111; font-size: 20px;">🔒 Password Reset Request</h2>
<p style="margin: 0 0 24px; color: #374151; font-size: 14px; line-height: 1.6;">
You requested to reset your LinkID password. Click the button below to set a new password. This link expires in <strong>1 hour</strong>.
</p>
<a href="${resetLink}" style="display: inline-block; background: #111; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-size: 14px; font-weight: 600;">
Reset Password
</a>
<p style="margin: 24px 0 0; color: #6b7280; font-size: 13px;">
If you did not request this, please ignore this email. Your account will remain safe.
</p>
</div>
`,
});

return NextResponse.json({ message: "If this email exists, a reset link has been sent." });
} catch (error) {
console.error("Forgot password error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
39 changes: 39 additions & 0 deletions app/api/auth/reset-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs";

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

if (!token || !password) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
}

const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
});

if (!resetToken || resetToken.expires < new Date()) {
return NextResponse.json({ error: "Invalid or expired token" }, { status: 400 });
}

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

await prisma.user.update({
where: { email: resetToken.email },
data: { password: hashedPassword },
});

await prisma.passwordResetToken.delete({ where: { token } });

return NextResponse.json({ message: "Password reset successfully" });
} catch (error) {
console.error("Reset password error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
83 changes: 83 additions & 0 deletions app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"use client";

import { useState } from "react";
import Link from "next/link";

export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");

try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await res.json();
setLoading(false);
if (!res.ok) {
setError(data.error || "Something went wrong");
} else {
setSubmitted(true);
}
} catch {
setLoading(false);
setError("Network error. Please try again.");
}
};

if (submitted) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold">Check your email</h1>
<p className="text-muted-foreground">
If that email exists, we&apos;ve sent a password reset link.
</p>
<Link href="/login" className="text-sm hover:underline">
Back to login
</Link>
</div>
</div>
);
}

return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-6 p-6">
<h1 className="text-2xl font-bold">Forgot Password</h1>
<p className="text-muted-foreground">
Enter your email and we&apos;ll send you a reset link.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full border rounded px-3 py-2"
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-2 rounded hover:bg-gray-800"
>
{loading ? "Sending..." : "Send Reset Link"}
</button>
</form>
<Link href="/login" className="text-sm hover:underline">
Back to login
</Link>
</div>
</div>
);
}
112 changes: 112 additions & 0 deletions app/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";

import { useState, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";

function ResetPasswordForm() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);

if (!token) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold">Invalid Link</h1>
<p className="text-muted-foreground">This password reset link is invalid.</p>
<Link href="/forgot-password" className="text-sm hover:underline">
Request a new link
</Link>
</div>
</div>
);
}

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirm) {
setError("Passwords do not match");
return;
}
setLoading(true);
setError("");

try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
setLoading(false);
if (!res.ok) {
setError(data.error || "Something went wrong");
} else {
setSuccess(true);
setTimeout(() => router.push("/login"), 2000);
}
} catch {
setLoading(false);
setError("Network error. Please try again.");
}
};

if (success) {
return (
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold">Password Reset!</h1>
<p className="text-muted-foreground">Redirecting you to login...</p>
</div>
);
}

return (
<div className="w-full max-w-md space-y-6 p-6">
<h1 className="text-2xl font-bold">Reset Password</h1>
<p className="text-muted-foreground">Enter your new password below.</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="password"
placeholder="New password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border rounded px-3 py-2"
/>
<input
type="password"
placeholder="Confirm new password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="w-full border rounded px-3 py-2"
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-2 rounded hover:bg-gray-800"
>
{loading ? "Resetting..." : "Reset Password"}
</button>
</form>
<Link href="/login" className="text-sm hover:underline">Back to login</Link>
</div>
);
}

export default function ResetPasswordPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<Suspense fallback={<div>Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</div>
);
}
15 changes: 14 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,17 @@ model InvalidatedSession {
createdAt DateTime @default(now())

@@map("invalidatedSession")
}
}

model PasswordResetToken {
id String @id @default(cuid())
email String
token String @unique
expires DateTime
createdAt DateTime @default(now())

@@index([email])
@@map("passwordResetToken")
}