146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { randomBytes, createHash } from "crypto";
|
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
function getAppOrigin(request: Request): string {
|
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
|
if (appUrl) return appUrl.replace(/\/$/, "");
|
|
|
|
const vercelUrl = process.env.VERCEL_URL?.trim();
|
|
if (vercelUrl) return `https://${vercelUrl}`;
|
|
|
|
return new URL(request.url).origin;
|
|
}
|
|
|
|
async function sendResetEmail(params: {
|
|
to: string;
|
|
resetUrl: string;
|
|
expiresAt: string;
|
|
}): Promise<void> {
|
|
const resendApiKey = process.env.RESEND_API_KEY?.trim();
|
|
const resendFromEmail = (
|
|
process.env.RESEND_FROM_EMAIL ||
|
|
process.env.EMAIL_FROM
|
|
)?.trim();
|
|
|
|
if (!resendApiKey || !resendFromEmail) {
|
|
throw new Error("Resend is not configured. Missing RESEND_API_KEY or RESEND_FROM_EMAIL.");
|
|
}
|
|
|
|
const response = await fetch("https://api.resend.com/emails", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${resendApiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
from: resendFromEmail,
|
|
to: [params.to],
|
|
subject: "Reset your password",
|
|
html: `
|
|
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#111827;">
|
|
<p>Password reset requested for your account.</p>
|
|
<p>
|
|
<a href="${params.resetUrl}" style="display:inline-block;padding:10px 14px;background:#111827;color:#ffffff;text-decoration:none;border-radius:6px;">
|
|
Reset Password
|
|
</a>
|
|
</p>
|
|
<p>This link expires at ${params.expiresAt}.</p>
|
|
<p>If you did not request this, you can ignore this email.</p>
|
|
</div>
|
|
`,
|
|
text: `Password reset requested.\n\nReset link: ${params.resetUrl}\n\nThis link expires at ${params.expiresAt}.\n\nIf you did not request this, ignore this email.`,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const details = await response.text().catch(() => "");
|
|
throw new Error(`Resend API error (${response.status}): ${details}`);
|
|
}
|
|
}
|
|
|
|
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 supabase = getServiceSupabase();
|
|
|
|
// Check if user exists
|
|
const { data: user } = await supabase
|
|
.from("users")
|
|
.select("id, email")
|
|
.eq("email", email)
|
|
.maybeSingle();
|
|
|
|
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
|
|
await supabase.from("password_reset_tokens").delete().eq("user_id", user.id);
|
|
|
|
// Store new token
|
|
await supabase.from("password_reset_tokens").insert({
|
|
user_id: user.id,
|
|
token_hash: tokenHash,
|
|
expires_at: expiresAt,
|
|
created_at: createdAt,
|
|
used: false,
|
|
});
|
|
|
|
// Build an absolute reset URL for email delivery.
|
|
const resetPath = `/reset-password?token=${token}&email=${encodeURIComponent(email)}`;
|
|
const resetUrl = `${getAppOrigin(request)}${resetPath}`;
|
|
|
|
try {
|
|
await sendResetEmail({ to: email, resetUrl, expiresAt });
|
|
console.log(`Password reset email sent to ${email}`);
|
|
} catch (sendError) {
|
|
console.error("Failed to send password reset email:", sendError);
|
|
|
|
// Keep local dev workflow usable even if email config is missing.
|
|
if (process.env.NODE_ENV !== "production") {
|
|
console.log(`\n🔐 PASSWORD RESET REQUEST for ${email}`);
|
|
console.log(` Reset URL: ${resetUrl}`);
|
|
console.log(` Token expires: ${expiresAt}\n`);
|
|
} else {
|
|
return NextResponse.json(
|
|
{ error: "Unable to send reset email. Please try again later." },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message: "If an account exists with that email, a reset link has been sent.",
|
|
// 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 });
|
|
}
|
|
} |