diff --git a/public/app.js b/public/app.js index 1ab2261..8643052 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,5 @@ var signupEmail = ''; +var recoverEmail = ''; function showState(state) { ['signupInitial', 'signupLoading', 'signupVerify', 'signupResult'].forEach(function(id) { @@ -8,6 +9,14 @@ function showState(state) { document.getElementById(state).classList.add('active'); } +function showRecoverState(state) { + ['recoverInitial', 'recoverLoading', 'recoverVerify', 'recoverResult'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.classList.remove('active'); + }); + document.getElementById(state).classList.add('active'); +} + function openSignup() { document.getElementById('signupModal').classList.add('active'); showState('signupInitial'); @@ -22,6 +31,23 @@ function closeSignup() { document.getElementById('signupModal').classList.remove('active'); } +function openRecover() { + closeSignup(); + document.getElementById('recoverModal').classList.add('active'); + showRecoverState('recoverInitial'); + var errEl = document.getElementById('recoverError'); + if (errEl) errEl.style.display = 'none'; + var verifyErrEl = document.getElementById('recoverVerifyError'); + if (verifyErrEl) verifyErrEl.style.display = 'none'; + document.getElementById('recoverEmailInput').value = ''; + document.getElementById('recoverCode').value = ''; + recoverEmail = ''; +} + +function closeRecover() { + document.getElementById('recoverModal').classList.remove('active'); +} + async function submitSignup() { var errEl = document.getElementById('signupError'); var btn = document.getElementById('signupBtn'); @@ -106,17 +132,117 @@ async function submitVerify() { } } +async function submitRecover() { + var errEl = document.getElementById('recoverError'); + var btn = document.getElementById('recoverBtn'); + var emailInput = document.getElementById('recoverEmailInput'); + var email = emailInput.value.trim(); + + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errEl.textContent = 'Please enter a valid email address.'; + errEl.style.display = 'block'; + return; + } + + errEl.style.display = 'none'; + btn.disabled = true; + showRecoverState('recoverLoading'); + + try { + var res = await fetch('/v1/recover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email }) + }); + var data = await res.json(); + + if (!res.ok) { + showRecoverState('recoverInitial'); + errEl.textContent = data.error || 'Something went wrong.'; + errEl.style.display = 'block'; + btn.disabled = false; + return; + } + + recoverEmail = email; + document.getElementById('recoverEmailDisplay').textContent = email; + showRecoverState('recoverVerify'); + document.getElementById('recoverCode').focus(); + btn.disabled = false; + } catch (err) { + showRecoverState('recoverInitial'); + errEl.textContent = 'Network error. Please try again.'; + errEl.style.display = 'block'; + btn.disabled = false; + } +} + +async function submitRecoverVerify() { + var errEl = document.getElementById('recoverVerifyError'); + var btn = document.getElementById('recoverVerifyBtn'); + var codeInput = document.getElementById('recoverCode'); + var code = codeInput.value.trim(); + + if (!code || !/^\d{6}$/.test(code)) { + errEl.textContent = 'Please enter a 6-digit code.'; + errEl.style.display = 'block'; + return; + } + + errEl.style.display = 'none'; + btn.disabled = true; + + try { + var res = await fetch('/v1/recover/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: recoverEmail, code: code }) + }); + var data = await res.json(); + + if (!res.ok) { + errEl.textContent = data.error || 'Verification failed.'; + errEl.style.display = 'block'; + btn.disabled = false; + return; + } + + if (data.apiKey) { + document.getElementById('recoveredKeyText').textContent = data.apiKey; + showRecoverState('recoverResult'); + } else { + errEl.textContent = data.message || 'No key found for this email.'; + errEl.style.display = 'block'; + btn.disabled = false; + } + } catch (err) { + errEl.textContent = 'Network error. Please try again.'; + errEl.style.display = 'block'; + btn.disabled = false; + } +} + function copyKey() { var key = document.getElementById('apiKeyText').textContent; var btn = document.getElementById('copyBtn'); + doCopy(key, btn); +} + +function copyRecoveredKey() { + var key = document.getElementById('recoveredKeyText').textContent; + var btn = document.getElementById('copyRecoveredBtn'); + doCopy(key, btn); +} + +function doCopy(text, btn) { function showCopied() { btn.textContent = '\u2713 Copied!'; setTimeout(function() { btn.textContent = 'Copy'; }, 2000); } try { - navigator.clipboard.writeText(key).then(showCopied).catch(function() { + navigator.clipboard.writeText(text).then(showCopied).catch(function() { var ta = document.createElement('textarea'); - ta.value = key; ta.style.position = 'fixed'; ta.style.opacity = '0'; + ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); @@ -146,6 +272,21 @@ document.addEventListener('DOMContentLoaded', function() { document.getElementById('signupBtn').addEventListener('click', submitSignup); document.getElementById('verifyBtn').addEventListener('click', submitVerify); document.getElementById('copyBtn').addEventListener('click', copyKey); + + // Recovery modal + document.getElementById('btn-close-recover').addEventListener('click', closeRecover); + document.getElementById('recoverBtn').addEventListener('click', submitRecover); + document.getElementById('recoverVerifyBtn').addEventListener('click', submitRecoverVerify); + document.getElementById('copyRecoveredBtn').addEventListener('click', copyRecoveredKey); + document.getElementById('recoverModal').addEventListener('click', function(e) { + if (e.target === this) closeRecover(); + }); + + // Open recovery from links + document.querySelectorAll('.open-recover').forEach(function(el) { + el.addEventListener('click', function(e) { e.preventDefault(); openRecover(); }); + }); + document.getElementById('signupModal').addEventListener('click', function(e) { if (e.target === this) closeSignup(); }); diff --git a/public/index.html b/public/index.html index 6c04a35..6335058 100644 --- a/public/index.html +++ b/public/index.html @@ -188,6 +188,13 @@ html, body { overflow-x: hidden !important; } } + +/* Recovery modal states */ +#recoverInitial, #recoverLoading, #recoverVerify, #recoverResult { display: none; } +#recoverInitial.active { display: block; } +#recoverLoading.active { display: flex; flex-direction: column; align-items: center; padding: 40px 0; text-align: center; } +#recoverResult.active { display: block; } +#recoverVerify.active { display: block; } @@ -349,7 +356,7 @@ html, body {
-

100 free PDFs/month β€’ All endpoints included

+

100 free PDFs/month β€’ All endpoints included
Lost your API key? Recover it β†’

@@ -370,7 +377,7 @@ html, body {

πŸš€ Your API key is ready!

⚠️ - Save your API key securely. Lost it? Recover via email + Save your API key securely. Lost it? Recover via email
@@ -381,6 +388,50 @@ html, body {
+ + + + diff --git a/src/index.ts b/src/index.ts index 6e9de17..b43190b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); // Differentiated CORS middleware app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || + req.path.startsWith('/v1/recover') || req.path.startsWith('/v1/billing'); if (isAuthBillingRoute) { @@ -59,7 +60,7 @@ app.set("trust proxy", 1); // Rate limiting const limiter = rateLimit({ windowMs: 60_000, - max: 100, + max: 30, standardHeaders: true, legacyHeaders: false, }); diff --git a/src/routes/recover.ts b/src/routes/recover.ts index 4b043bd..f7a9031 100644 --- a/src/routes/recover.ts +++ b/src/routes/recover.ts @@ -1,20 +1,20 @@ import { Router, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import { createPendingVerification, verifyCode } from "../services/verification.js"; -import { sendRecoveryEmail, sendVerificationEmail } from "../services/email.js"; +import { sendVerificationEmail } from "../services/email.js"; import { getAllKeys } from "../services/keys.js"; const router = Router(); const recoverLimiter = rateLimit({ windowMs: 60 * 60 * 1000, - max: 3, + max: 5, message: { error: "Too many recovery attempts. Please try again in 1 hour." }, standardHeaders: true, legacyHeaders: false, }); -// Step 1: Request recovery β€” sends verification code +// Step 1: Request recovery β€” sends verification code via email router.post("/", recoverLimiter, async (req: Request, res: Response) => { const { email } = req.body || {}; @@ -37,6 +37,7 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => { const pending = createPendingVerification(cleanEmail); + // Send verification CODE only β€” NEVER send the API key via email sendVerificationEmail(cleanEmail, pending.code).catch(err => { console.error(`Failed to send recovery email to ${cleanEmail}:`, err); }); @@ -44,7 +45,7 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => { res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); }); -// Step 2: Verify code β€” sends API key via email (NOT in response) +// Step 2: Verify code β€” returns API key in response (NEVER via email) router.post("/verify", recoverLimiter, async (req: Request, res: Response) => { const { email, code } = req.body || {}; @@ -64,15 +65,19 @@ router.post("/verify", recoverLimiter, async (req: Request, res: Response) => { const userKey = keys.find(k => k.email === cleanEmail); if (userKey) { - sendRecoveryEmail(cleanEmail, userKey.key).catch(err => { - console.error(`Failed to send recovery key to ${cleanEmail}:`, err); + // Return key in response β€” shown once in browser, never emailed + res.json({ + status: "recovered", + apiKey: userKey.key, + tier: userKey.tier, + message: "Your API key has been recovered. Save it securely β€” it is shown only once.", + }); + } else { + res.json({ + status: "recovered", + message: "No API key found for this email.", }); } - - res.json({ - status: "recovered", - message: "Your API key has been sent to your email address.", - }); break; } case "expired": diff --git a/src/services/email.ts b/src/services/email.ts index 5c62043..639c653 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -16,7 +16,7 @@ export async function sendVerificationEmail(email: string, code: string): Promis from: "DocFast ", to: email, subject: "DocFast - Verify your email", - text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't sign up for DocFast, ignore this email.`, + text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.`, }); console.log(`πŸ“§ Verification email sent to ${email}: ${info.messageId}`); return true; @@ -26,18 +26,5 @@ export async function sendVerificationEmail(email: string, code: string): Promis } } -export async function sendRecoveryEmail(email: string, apiKey: string): Promise { - try { - const info = await transporter.sendMail({ - from: "DocFast ", - to: email, - subject: "DocFast - Your API Key Recovery", - text: `Here is your DocFast API key:\n\n${apiKey}\n\nKeep this key safe. Do not share it with anyone.\n\nIf you didn't request this recovery, please ignore this email β€” your key has not been changed.`, - }); - console.log(`πŸ“§ Recovery email sent to ${email}: ${info.messageId}`); - return true; - } catch (err) { - console.error(`πŸ“§ Failed to send recovery email to ${email}:`, err); - return false; - } -} +// NOTE: sendRecoveryEmail removed β€” API keys must NEVER be sent via email. +// Key recovery now shows the key in the browser after code verification.