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

- 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:
Antoni Nuñez Romeu
2026-07-27 15:57:54 +02:00
parent 271d23b072
commit 87df61ab15
15 changed files with 839 additions and 1 deletions
+110
View File
@@ -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 {