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:
parent
890b82e5ec
commit
1b20665b0d
7 changed files with 252 additions and 29 deletions
52
src/services/email.ts
Normal file
52
src/services/email.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||
const BASE_URL = process.env.BASE_URL || "https://docfast.dev";
|
||||
|
||||
export async function sendVerificationEmail(email: string, token: string): Promise<boolean> {
|
||||
const verifyUrl = `${BASE_URL}/verify?token=${token}`;
|
||||
|
||||
if (!RESEND_API_KEY || RESEND_API_KEY === "FILL_IN") {
|
||||
console.log(`\n📧 VERIFICATION EMAIL (no Resend configured)`);
|
||||
console.log(` To: ${email}`);
|
||||
console.log(` URL: ${verifyUrl}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: "DocFast <noreply@docfast.dev>",
|
||||
to: [email],
|
||||
subject: "Verify your DocFast account",
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h1 style="font-size: 24px; margin-bottom: 16px;">⚡ Welcome to DocFast</h1>
|
||||
<p style="color: #555; line-height: 1.6;">Click the button below to verify your email and get your API key:</p>
|
||||
<a href="${verifyUrl}" style="display: inline-block; background: #34d399; color: #0b0d11; padding: 14px 28px; border-radius: 8px; font-weight: 600; text-decoration: none; margin: 24px 0;">Verify Email →</a>
|
||||
<p style="color: #999; font-size: 13px; margin-top: 24px;">This link expires in 24 hours. If you didn't sign up for DocFast, ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
console.error(`Resend API error: ${res.status} ${err}`);
|
||||
// Fallback to console
|
||||
console.log(`\n📧 VERIFICATION EMAIL (Resend failed, logging)`);
|
||||
console.log(` To: ${email}`);
|
||||
console.log(` URL: ${verifyUrl}\n`);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Email send error:", err);
|
||||
console.log(`\n📧 VERIFICATION EMAIL (fallback)`);
|
||||
console.log(` To: ${email}`);
|
||||
console.log(` URL: ${verifyUrl}\n`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
98
src/services/verification.ts
Normal file
98
src/services/verification.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { randomBytes } from "crypto";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, "../../data");
|
||||
const DB_PATH = path.join(DATA_DIR, "verifications.json");
|
||||
|
||||
// Simple JSON-based store (no SQLite dependency needed)
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
export interface Verification {
|
||||
email: string;
|
||||
token: string;
|
||||
apiKey: string;
|
||||
createdAt: string;
|
||||
verifiedAt: string | null;
|
||||
}
|
||||
|
||||
let verifications: Verification[] = [];
|
||||
|
||||
function ensureDataDir(): void {
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function load(): void {
|
||||
ensureDataDir();
|
||||
if (existsSync(DB_PATH)) {
|
||||
try {
|
||||
verifications = JSON.parse(readFileSync(DB_PATH, "utf-8"));
|
||||
} catch {
|
||||
verifications = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
ensureDataDir();
|
||||
writeFileSync(DB_PATH, JSON.stringify(verifications, null, 2));
|
||||
}
|
||||
|
||||
// Initialize on import
|
||||
load();
|
||||
|
||||
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export function createVerification(email: string, apiKey: string): Verification {
|
||||
// Check for existing pending verification for this email
|
||||
const existing = verifications.find(
|
||||
(v) => v.email === email && !v.verifiedAt
|
||||
);
|
||||
if (existing) {
|
||||
// Check if expired
|
||||
const age = Date.now() - new Date(existing.createdAt).getTime();
|
||||
if (age < TOKEN_EXPIRY_MS) {
|
||||
return existing; // Return existing pending verification
|
||||
}
|
||||
// Expired — remove it
|
||||
verifications = verifications.filter((v) => v !== existing);
|
||||
}
|
||||
|
||||
const verification: Verification = {
|
||||
email,
|
||||
token: randomBytes(32).toString("hex"),
|
||||
apiKey,
|
||||
createdAt: new Date().toISOString(),
|
||||
verifiedAt: null,
|
||||
};
|
||||
verifications.push(verification);
|
||||
save();
|
||||
return verification;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
|
||||
const v = verifications.find((v) => v.token === token);
|
||||
if (!v) return { status: "invalid" };
|
||||
|
||||
if (v.verifiedAt) return { status: "already_verified", verification: v };
|
||||
|
||||
const age = Date.now() - new Date(v.createdAt).getTime();
|
||||
if (age > TOKEN_EXPIRY_MS) return { status: "expired" };
|
||||
|
||||
v.verifiedAt = new Date().toISOString();
|
||||
save();
|
||||
return { status: "ok", verification: v };
|
||||
}
|
||||
|
||||
export function isEmailVerified(email: string): boolean {
|
||||
return verifications.some((v) => v.email === email && v.verifiedAt !== null);
|
||||
}
|
||||
|
||||
export function getVerifiedApiKey(email: string): string | null {
|
||||
const v = verifications.find((v) => v.email === email && v.verifiedAt !== null);
|
||||
return v?.apiKey ?? null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue