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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user