Add forgot password feature with reset flow
This commit is contained in:
parent
ae5c952ab1
commit
a62afb95e7
99
src/app/api/auth/forgot-password/route.ts
Normal file
99
src/app/api/auth/forgot-password/route.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Database from "better-sqlite3";
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import { join } from "path";
|
||||
|
||||
const DATA_DIR = join(process.cwd(), "data");
|
||||
const DB_FILE = join(DATA_DIR, "tasks.db");
|
||||
|
||||
function getDb() {
|
||||
const database = new Database(DB_FILE);
|
||||
database.pragma("journal_mode = WAL");
|
||||
|
||||
// Create password reset tokens table
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
tokenHash TEXT NOT NULL UNIQUE,
|
||||
expiresAt TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
used INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reset_tokens_hash ON password_reset_tokens(tokenHash);
|
||||
CREATE INDEX IF NOT EXISTS idx_reset_tokens_user ON password_reset_tokens(userId);
|
||||
`);
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as { email?: string };
|
||||
const email = (body.email || "").trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check if user exists
|
||||
const user = db.prepare("SELECT id, email FROM users WHERE email = ? LIMIT 1").get(email) as
|
||||
| { id: string; email: string }
|
||||
| undefined;
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists or not for security
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "If an account exists with that email, a reset link has been sent."
|
||||
});
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const tokenHash = hashToken(token);
|
||||
const now = Date.now();
|
||||
const expiresAt = new Date(now + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||
const createdAt = new Date(now).toISOString();
|
||||
|
||||
// Invalidate old tokens for this user
|
||||
db.prepare("DELETE FROM password_reset_tokens WHERE userId = ?").run(user.id);
|
||||
|
||||
// Store new token
|
||||
db.prepare(
|
||||
"INSERT INTO password_reset_tokens (id, userId, tokenHash, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
`reset-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
createdAt
|
||||
);
|
||||
|
||||
// In production, you would send an email here
|
||||
// For now, log to console and return the reset link
|
||||
const resetUrl = `/reset-password?token=${token}&email=${encodeURIComponent(email)}`;
|
||||
console.log(`\n🔐 PASSWORD RESET REQUEST for ${email}`);
|
||||
console.log(` Reset URL: ${resetUrl}`);
|
||||
console.log(` Token expires: ${expiresAt}\n`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Password reset link generated. Check server logs for the link.",
|
||||
// In dev, include the reset URL
|
||||
...(process.env.NODE_ENV !== "production" && { resetUrl })
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Forgot password error:", error);
|
||||
return NextResponse.json({ error: "Failed to process request" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
113
src/app/api/auth/reset-password/route.ts
Normal file
113
src/app/api/auth/reset-password/route.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Database from "better-sqlite3";
|
||||
import { createHash, randomBytes, scryptSync } from "crypto";
|
||||
import { join } from "path";
|
||||
|
||||
const DATA_DIR = join(process.cwd(), "data");
|
||||
const DB_FILE = join(DATA_DIR, "tasks.db");
|
||||
|
||||
function getDb() {
|
||||
const database = new Database(DB_FILE);
|
||||
database.pragma("journal_mode = WAL");
|
||||
return database;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function hashPassword(password: string, salt?: string): string {
|
||||
const safeSalt = salt || randomBytes(16).toString("hex");
|
||||
const derived = scryptSync(password, safeSalt, 64).toString("hex");
|
||||
return `scrypt$${safeSalt}$${derived}`;
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
token?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
const token = body.token || "";
|
||||
const email = (body.email || "").trim().toLowerCase();
|
||||
const password = body.password || "";
|
||||
|
||||
if (!token || !email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token, email, and password are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 8 characters" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const tokenHash = hashToken(token);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Find valid token
|
||||
const resetToken = db.prepare(
|
||||
`SELECT rt.*, u.id as userId, u.email
|
||||
FROM password_reset_tokens rt
|
||||
JOIN users u ON u.id = rt.userId
|
||||
WHERE rt.tokenHash = ?
|
||||
AND rt.used = 0
|
||||
AND rt.expiresAt > ?
|
||||
LIMIT 1`
|
||||
).get(tokenHash, now) as
|
||||
| { id: string; userId: string; email: string }
|
||||
| undefined;
|
||||
|
||||
if (!resetToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or expired reset token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (resetToken.email.toLowerCase() !== email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid reset token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = hashPassword(password);
|
||||
|
||||
// Update user password
|
||||
db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run(
|
||||
passwordHash,
|
||||
resetToken.userId
|
||||
);
|
||||
|
||||
// Mark token as used
|
||||
db.prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?").run(
|
||||
resetToken.id
|
||||
);
|
||||
|
||||
// Delete all sessions for this user (force re-login)
|
||||
db.prepare("DELETE FROM sessions WHERE userId = ?").run(resetToken.userId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Password reset successfully",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Reset password error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reset password" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,8 @@ export default function LoginPage() {
|
||||
const [rememberMe, setRememberMe] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [resetSent, setResetSent] = useState(false)
|
||||
const [showForgot, setShowForgot] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
@ -117,7 +119,18 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">Password</label>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm text-slate-300">Password</label>
|
||||
{mode === "login" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgot(true)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
@ -142,6 +155,106 @@ export default function LoginPage() {
|
||||
{isSubmitting ? "Please wait..." : mode === "login" ? "Login" : "Create Account"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Forgot Password Modal */}
|
||||
{showForgot && (
|
||||
<ForgotPasswordModal
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
onClose={() => setShowForgot(false)}
|
||||
onSent={() => setResetSent(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reset Success Message */}
|
||||
{resetSent && (
|
||||
<div className="mt-4 p-4 bg-green-900/30 border border-green-700 rounded-lg">
|
||||
<p className="text-sm text-green-400">
|
||||
✓ Password reset link sent! Check your email.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ForgotPasswordModal({
|
||||
email,
|
||||
setEmail,
|
||||
onClose,
|
||||
onSent,
|
||||
}: {
|
||||
email: string
|
||||
setEmail: (email: string) => void
|
||||
onClose: () => void
|
||||
onSent: () => void
|
||||
}) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null)
|
||||
|
||||
if (!email.trim()) {
|
||||
setError("Email is required")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
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()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to send reset link")
|
||||
return
|
||||
}
|
||||
|
||||
onSent()
|
||||
onClose()
|
||||
} catch {
|
||||
setError("Failed to send reset link")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900 p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Reset Password</h2>
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
Enter your email and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onClose} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="flex-1" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Sending..." : "Send Link"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
152
src/app/reset-password/page.tsx
Normal file
152
src/app/reset-password/page.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get("token")
|
||||
const email = searchParams.get("email")
|
||||
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !email) {
|
||||
setError("Invalid or missing reset link")
|
||||
}
|
||||
}, [token, email])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null)
|
||||
|
||||
if (!password || !confirmPassword) {
|
||||
setError("Please enter and confirm your new password")
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, email, password }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to reset password")
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push("/login")
|
||||
}, 3000)
|
||||
} catch {
|
||||
setError("Failed to reset password")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md border border-green-800 rounded-xl bg-green-900/20 p-6 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-white mb-2">Password Reset!</h1>
|
||||
<p className="text-slate-300 mb-4">
|
||||
Your password has been successfully reset.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Redirecting to login...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900/70 p-6">
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Reset Password</h1>
|
||||
<p className="text-sm text-slate-400 mb-6">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||
placeholder="Re-enter password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSubmit} className="w-full" disabled={isSubmitting || !token || !email}>
|
||||
{isSubmitting ? "Resetting..." : "Reset Password"}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => router.push("/login")} variant="outline" className="w-full">
|
||||
Back to Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-slate-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user