From ee8158993f5a5f9586fde5df86641a12d6108ba2 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sat, 21 Feb 2026 13:37:59 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- package-lock.json | 75 ++++++++++++++++++- package.json | 3 +- src/app/api/auth/forgot-password/route.ts | 87 +++++++++++++++++++++-- 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9bc4560..22c6a64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "@supabase/supabase-js": "^2.97.0", "better-sqlite3": "^12.6.2", "dotenv": "^16.6.1", - "firebase": "^12.9.0" + "firebase": "^12.9.0", + "resend": "^6.9.2" }, "devDependencies": { "@radix-ui/react-slot": "^1.2.4", @@ -3361,6 +3362,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6294,6 +6301,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -8443,6 +8456,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -8868,6 +8887,27 @@ "dev": true, "license": "MIT" }, + "node_modules/resend": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -9305,6 +9345,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9546,6 +9596,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz", @@ -10040,6 +10100,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", diff --git a/package.json b/package.json index 4e9f7b6..e4ea7bf 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "@supabase/supabase-js": "^2.97.0", "better-sqlite3": "^12.6.2", "dotenv": "^16.6.1", - "firebase": "^12.9.0" + "firebase": "^12.9.0", + "resend": "^6.9.2" }, "devDependencies": { "@radix-ui/react-slot": "^1.2.4", diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts index 8e15a02..fe89bb9 100644 --- a/src/app/api/auth/forgot-password/route.ts +++ b/src/app/api/auth/forgot-password/route.ts @@ -8,6 +8,63 @@ 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 { + 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: ` +
+

Password reset requested for your account.

+

+ + Reset Password + +

+

This link expires at ${params.expiresAt}.

+

If you did not request this, you can ignore this email.

+
+ `, + 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 }; @@ -53,16 +110,32 @@ export async function POST(request: Request) { used: false, }); - // 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`); + // 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: "Password reset link generated. Check server logs for the link.", + 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 }), });