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 [rememberMe, setRememberMe] = useState(true)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [resetSent, setResetSent] = useState(false)
|
||||||
|
const [showForgot, setShowForgot] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true
|
let isMounted = true
|
||||||
@ -117,7 +119,18 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@ -142,6 +155,106 @@ export default function LoginPage() {
|
|||||||
{isSubmitting ? "Please wait..." : mode === "login" ? "Login" : "Create Account"}
|
{isSubmitting ? "Please wait..." : mode === "login" ? "Login" : "Create Account"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
</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