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..f9c8361 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -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 }); + } +} 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..6004a84 --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -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 } + ); + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ec26344..8761c63 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -13,6 +13,8 @@ export default function LoginPage() { const [rememberMe, setRememberMe] = useState(true) const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) + const [resetSent, setResetSent] = useState(false) + const [showForgot, setShowForgot] = useState(false) useEffect(() => { let isMounted = true @@ -117,7 +119,18 @@ export default function LoginPage() {
- +
+ + {mode === "login" && ( + + )} +
+ + {/* Forgot Password Modal */} + {showForgot && ( + setShowForgot(false)} + onSent={() => setResetSent(true)} + /> + )} + + {/* Reset Success Message */} + {resetSent && ( +
+

+ āœ“ Password reset link sent! Check your email. +

+
+ )} + + + ) +} + +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(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 ( +
+
+

Reset Password

+

+ Enter your email and we'll send you a link to reset your password. +

+ +
+
+ + 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" + /> +
+ + {error &&

{error}

} + +
+ + +
+
) diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx new file mode 100644 index 0000000..b7debcd --- /dev/null +++ b/src/app/reset-password/page.tsx @@ -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(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 ( +
+
+
+ + + +
+

Password Reset!

+

+ Your password has been successfully reset. +

+

+ Redirecting to login... +

+
+
+ ) + } + + return ( +
+
+

Reset Password

+

+ Enter your new password below. +

+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + + + +
+
+
+ ) +} + +export default function ResetPasswordPage() { + return ( + +
+

Loading...

+
+ + }> + +
+ ) +}