Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-21 13:37:59 -06:00
parent bd2261c82f
commit ee8158993f
3 changed files with 156 additions and 9 deletions

75
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<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 };
@ -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 }),
});