feat: add forgot password and username recovery flow
Run Tests on Branches / Detect Changes (push) Successful in 14s
Run Tests on Branches / Backend Tests (push) Successful in 2m24s
Run Tests on Branches / Frontend Tests (push) Successful in 1m57s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Detect Changes (push) Successful in 14s
Run Tests on Branches / Backend Tests (push) Successful in 2m24s
Run Tests on Branches / Frontend Tests (push) Successful in 1m57s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
- Add Mailpit SMTP container to docker-compose for dev email capture - Add nodemailer dependency for email sending - Add password_reset_tokens table (PG + SQLite) - Add POST /api/auth/forgot-username endpoint - Add POST /api/auth/forgot-password endpoint (token-based, 1h expiry) - Add POST /api/auth/reset-password endpoint - Create ForgotPasswordModal component (choose mode → email → sent) - Create ResetPasswordView (token-based new password form) - Add forgot/recovery links to LoginModal - Add i18n translations (ES/CA) for the full flow
This commit is contained in:
@@ -24,5 +24,16 @@ EXPO_ACCESS_TOKEN=
|
||||
# Genera una cadena aleatoria fuerte, p.ej.: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
INGEST_API_KEY=dev-ingest-key-change-me
|
||||
|
||||
# SMTP for password reset / forgot username emails
|
||||
# Development: use Mailpit (docker) — no auth needed
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=noreply@farmafinder.com
|
||||
|
||||
# Base URL for reset links in emails (your frontend URL)
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# Parapharmacy API
|
||||
PARAPHARMACY_API_URL=http://localhost:3002
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.13.0",
|
||||
"pino": "^9.4.0",
|
||||
"pino-http": "^10.3.0",
|
||||
|
||||
@@ -25,9 +25,11 @@ import pinoHttp from 'pino-http';
|
||||
import multer from 'multer';
|
||||
import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.js';
|
||||
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
||||
import { sendPasswordResetEmail, sendForgotUsernameEmail } from './src/email.js';
|
||||
import { fetchPharmaciesExternal } from '../API/index.js';
|
||||
import { validateProductionEnv } from './src/config/required-env.js';
|
||||
import { isOpenNow, isAlwaysOpen } from './src/hours.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
validateProductionEnv();
|
||||
|
||||
@@ -119,6 +121,7 @@ const searchLimiter = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: tr
|
||||
const loginLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false, handler: limitHandler('login') });
|
||||
const registerLimiter = rateLimit({ windowMs: 60 * 60_000, max: 10, standardHeaders: true, legacyHeaders: false, handler: limitHandler('register') });
|
||||
const geocodeLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false, handler: limitHandler('geocode') });
|
||||
const forgotPasswordLimiter = rateLimit({ windowMs: 60_000, max: 3, standardHeaders: true, legacyHeaders: false, handler: limitHandler('forgot-password') });
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
|
||||
@@ -602,6 +605,33 @@ if (!pgPool) {
|
||||
)
|
||||
`);
|
||||
}
|
||||
// Password reset tokens table
|
||||
if (pgPool) {
|
||||
await pgPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_pwd_reset_token ON password_reset_tokens(token)`);
|
||||
} else {
|
||||
await dbRun(`
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pwd_reset_token ON password_reset_tokens(token)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('initDatabase failed:', err);
|
||||
throw err;
|
||||
@@ -1204,6 +1234,86 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Forgot username — send username to user's email
|
||||
app.post('/api/auth/forgot-username', forgotPasswordLimiter, async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body || {};
|
||||
if (!email || !String(email).trim()) {
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
const user = await userDbGet('SELECT username, email FROM users WHERE email = ?', [String(email).trim()]);
|
||||
if (user) {
|
||||
try {
|
||||
await sendForgotUsernameEmail(user.email, user.username);
|
||||
} catch (err) {
|
||||
console.error('Error sending forgot-username email:', err);
|
||||
}
|
||||
}
|
||||
res.json({ message: 'If the email exists, you will receive a message with your username.' });
|
||||
} catch (error) {
|
||||
console.error('Error in forgot-username:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Forgot password — generate reset token and send email
|
||||
app.post('/api/auth/forgot-password', forgotPasswordLimiter, async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body || {};
|
||||
if (!email || !String(email).trim()) {
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
const user = await userDbGet('SELECT id, username, email FROM users WHERE email = ?', [String(email).trim()]);
|
||||
if (user) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
await userDbRun(
|
||||
'INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
|
||||
[user.id, token, expiresAt.toISOString()]
|
||||
);
|
||||
try {
|
||||
await sendPasswordResetEmail(user.email, user.username, token);
|
||||
} catch (err) {
|
||||
console.error('Error sending password reset email:', err);
|
||||
}
|
||||
}
|
||||
res.json({ message: 'If the email exists, you will receive a password reset link.' });
|
||||
} catch (error) {
|
||||
console.error('Error in forgot-password:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset password — validate token and update password
|
||||
app.post('/api/auth/reset-password', forgotPasswordLimiter, async (req, res) => {
|
||||
try {
|
||||
const { token, password } = req.body || {};
|
||||
if (!token) return res.status(400).json({ error: 'Token is required' });
|
||||
if (!password || String(password).length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
const row = await userDbGet(
|
||||
'SELECT id, user_id, used, expires_at FROM password_reset_tokens WHERE token = ?',
|
||||
[token]
|
||||
);
|
||||
if (!row) return res.status(400).json({ error: 'Invalid or expired token' });
|
||||
if (row.used) return res.status(400).json({ error: 'Token already used' });
|
||||
|
||||
const expiresAt = new Date(row.expires_at);
|
||||
if (isNaN(expiresAt.getTime()) || expiresAt < new Date()) return res.status(400).json({ error: 'Token has expired' });
|
||||
|
||||
const passwordHash = await bcrypt.hash(String(password), 10);
|
||||
await userDbRun('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, row.user_id]);
|
||||
await userDbRun('UPDATE password_reset_tokens SET used = 1 WHERE id = ?', [row.id]);
|
||||
|
||||
res.json({ message: 'Password updated successfully.' });
|
||||
} catch (error) {
|
||||
console.error('Error in reset-password:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check authentication status — reads fresh user record so profile fields stay current
|
||||
app.get('/api/auth/check', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost';
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '1025', 10);
|
||||
const SMTP_USER = process.env.SMTP_USER || '';
|
||||
const SMTP_PASS = process.env.SMTP_PASS || '';
|
||||
const SMTP_FROM = process.env.SMTP_FROM || 'noreply@farmafinder.com';
|
||||
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
|
||||
|
||||
let transporter = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (transporter) return transporter;
|
||||
const auth = SMTP_USER && SMTP_PASS ? { user: SMTP_USER, pass: SMTP_PASS } : undefined;
|
||||
transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth,
|
||||
ignoreTLS: SMTP_PORT !== 465 && !SMTP_USER,
|
||||
});
|
||||
return transporter;
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(email, username, token) {
|
||||
const resetUrl = `${APP_URL}/reset-password?token=${token}`;
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<h2>Restablecer contraseña — FarmaFinder</h2>
|
||||
<p>Hola <strong>${username}</strong>,</p>
|
||||
<p>Has solicitado restablecer tu contraseña. Haz clic en el siguiente enlace para crear una nueva:</p>
|
||||
<p style="text-align: center; margin: 32px 0;">
|
||||
<a href="${resetUrl}"
|
||||
style="background: #2563eb; color: #fff; padding: 12px 24px; border-radius: 6px; text-decoration: none; display: inline-block;">
|
||||
Restablecer contraseña
|
||||
</a>
|
||||
</p>
|
||||
<p>Si no has solicitado este cambio, ignora este mensaje.</p>
|
||||
<p>El enlace caduca en 1 hora.</p>
|
||||
<hr style="margin-top: 32px; border: none; border-top: 1px solid #e5e7eb;">
|
||||
<p style="color: #6b7280; font-size: 12px;">FarmaFinder — Encuentra tus medicamentos en farmacias cercanas</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const text = `Restablecer contraseña — FarmaFinder\n\nHola ${username},\n\nHas solicitado restablecer tu contraseña. Abre este enlace para crear una nueva:\n${resetUrl}\n\nSi no has solicitado este cambio, ignora este mensaje.\nEl enlace caduca en 1 hora.`;
|
||||
|
||||
await getTransporter().sendMail({
|
||||
from: SMTP_FROM,
|
||||
to: email,
|
||||
subject: 'Restablece tu contraseña — FarmaFinder',
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendForgotUsernameEmail(email, username) {
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<h2>Tu usuario — FarmaFinder</h2>
|
||||
<p>Has solicitado recordar tu nombre de usuario.</p>
|
||||
<p style="font-size: 20px; text-align: center; padding: 16px; background: #f3f4f6; border-radius: 6px; margin: 24px 0;">
|
||||
<strong>${username}</strong>
|
||||
</p>
|
||||
<p>Puedes iniciar sesión con este usuario y tu contraseña.</p>
|
||||
<p>Si no has solicitado este dato, ignora este mensaje.</p>
|
||||
<hr style="margin-top: 32px; border: none; border-top: 1px solid #e5e7eb;">
|
||||
<p style="color: #6b7280; font-size: 12px;">FarmaFinder — Encuentra tus medicamentos en farmacias cercanas</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const text = `Tu usuario — FarmaFinder\n\nHas solicitado recordar tu nombre de usuario.\n\nTu usuario es: ${username}\n\nSi no has solicitado este dato, ignora este mensaje.`;
|
||||
|
||||
await getTransporter().sendMail({
|
||||
from: SMTP_FROM,
|
||||
to: email,
|
||||
subject: 'Tu nombre de usuario — FarmaFinder',
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user