Add forgot password feature with reset flow

This commit is contained in:
OpenClaw Bot 2026-02-20 18:51:04 -06:00
parent ae5c952ab1
commit a62afb95e7
4 changed files with 478 additions and 1 deletions

View 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 });
}
}

View 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 }
);
}
}

View File

@ -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&apos;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>
)

View 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>
)
}