feat: email verification for free tier signup

- Signup now requires email verification before API key is revealed
- Verification token sent via email (Resend) with console fallback
- GET /verify?token=xxx shows API key in styled HTML page
- Handles expired (24h), invalid, and already-verified tokens
- Frontend modal shows 'check your email' instead of key
- Keeps existing rate limiting
This commit is contained in:
OpenClaw 2026-02-14 18:12:25 +00:00
parent 890b82e5ec
commit 1b20665b0d
7 changed files with 252 additions and 29 deletions

View file

@ -13,6 +13,7 @@ import { usageMiddleware } from "./middleware/usage.js";
import { getUsageStats } from "./middleware/usage.js";
import { initBrowser, closeBrowser } from "./services/browser.js";
import { loadKeys, getAllKeys } from "./services/keys.js";
import { verifyToken } from "./services/verification.js";
const app = express();
const PORT = parseInt(process.env.PORT || "3100", 10);
@ -77,6 +78,63 @@ app.get("/v1/usage", authMiddleware, (_req, res) => {
res.json(getUsageStats());
});
// Email verification endpoint
app.get("/verify", (req, res) => {
const token = req.query.token as string;
if (!token) {
res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null));
return;
}
const result = verifyToken(token);
switch (result.status) {
case "ok":
res.send(verifyPage("Email Verified! 🚀", "Your DocFast API key is ready:", result.verification!.apiKey));
break;
case "already_verified":
res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification!.apiKey));
break;
case "expired":
res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null));
break;
case "invalid":
res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null));
break;
}
});
function verifyPage(title: string, message: string, apiKey: string | null): string {
return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} DocFast</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',sans-serif;background:#0b0d11;color:#e4e7ed;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{background:#151922;border:1px solid #1e2433;border-radius:16px;padding:48px;max-width:520px;width:100%;text-align:center}
h1{font-size:1.8rem;margin-bottom:12px;font-weight:800}
p{color:#7a8194;margin-bottom:24px;line-height:1.6}
.key-box{background:#0b0d11;border:1px solid #34d399;border-radius:8px;padding:16px;font-family:monospace;font-size:0.82rem;word-break:break-all;margin:16px 0;cursor:pointer;transition:background 0.2s;position:relative}
.key-box:hover{background:#12151c}
.key-box::after{content:'Click to copy';position:absolute;top:-24px;right:0;font-size:0.7rem;color:#7a8194;font-family:'Inter',sans-serif}
.warning{background:rgba(251,191,36,0.06);border:1px solid rgba(251,191,36,0.15);border-radius:8px;padding:12px 16px;font-size:0.85rem;color:#fbbf24;margin-bottom:16px;text-align:left}
.links{margin-top:24px;color:#7a8194;font-size:0.9rem}
.links a{color:#34d399;text-decoration:none}
.links a:hover{color:#5eead4}
</style></head><body>
<div class="card">
<h1>${title}</h1>
<p>${message}</p>
${apiKey ? `
<div class="warning"> Save your API key now we can't recover it later.</div>
<div class="key-box" onclick="navigator.clipboard.writeText('${apiKey}');this.style.borderColor='#5eead4';setTimeout(()=>this.style.borderColor='#34d399',1500)">${apiKey}</div>
<div class="links">100 free PDFs/month · <a href="/docs">Read the docs </a></div>
` : `<div class="links"><a href="/"> Back to DocFast</a></div>`}
</div></body></html>`;
}
// Landing page
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, "../public")));