From 87df61ab15181516a8a4fdbe03e7bf49185de68d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 27 Jul 2026 15:57:54 +0200 Subject: [PATCH] feat: add forgot password and username recovery flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/backend/.env.example | 11 ++ apps/backend/package.json | 1 + apps/backend/server.js | 110 ++++++++++++ apps/backend/src/email.js | 86 ++++++++++ apps/frontend/src/App.jsx | 44 +++++ .../src/components/ForgotPasswordModal.css | 77 +++++++++ .../src/components/ForgotPasswordModal.jsx | 159 ++++++++++++++++++ apps/frontend/src/components/LoginModal.css | 24 +++ apps/frontend/src/components/LoginModal.jsx | 20 ++- apps/frontend/src/i18n/locales/ca.js | 39 +++++ apps/frontend/src/i18n/locales/es.js | 39 +++++ apps/frontend/src/views/ResetPasswordView.css | 100 +++++++++++ apps/frontend/src/views/ResetPasswordView.jsx | 109 ++++++++++++ docker-compose.yml | 12 ++ package-lock.json | 9 + 15 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/email.js create mode 100644 apps/frontend/src/components/ForgotPasswordModal.css create mode 100644 apps/frontend/src/components/ForgotPasswordModal.jsx create mode 100644 apps/frontend/src/views/ResetPasswordView.css create mode 100644 apps/frontend/src/views/ResetPasswordView.jsx diff --git a/apps/backend/.env.example b/apps/backend/.env.example index b523cc5..2d6a443 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -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 diff --git a/apps/backend/package.json b/apps/backend/package.json index e8dbdee..12fb476 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/server.js b/apps/backend/server.js index 771015f..c121271 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -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 { diff --git a/apps/backend/src/email.js b/apps/backend/src/email.js new file mode 100644 index 0000000..48469ff --- /dev/null +++ b/apps/backend/src/email.js @@ -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 = ` + + + + +

Restablecer contraseña — FarmaFinder

+

Hola ${username},

+

Has solicitado restablecer tu contraseña. Haz clic en el siguiente enlace para crear una nueva:

+

+ + Restablecer contraseña + +

+

Si no has solicitado este cambio, ignora este mensaje.

+

El enlace caduca en 1 hora.

+
+

FarmaFinder — Encuentra tus medicamentos en farmacias cercanas

+ + + `; + 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 = ` + + + + +

Tu usuario — FarmaFinder

+

Has solicitado recordar tu nombre de usuario.

+

+ ${username} +

+

Puedes iniciar sesión con este usuario y tu contraseña.

+

Si no has solicitado este dato, ignora este mensaje.

+
+

FarmaFinder — Encuentra tus medicamentos en farmacias cercanas

+ + + `; + 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, + }); +} diff --git a/apps/frontend/src/App.jsx b/apps/frontend/src/App.jsx index fb54552..b2153f1 100644 --- a/apps/frontend/src/App.jsx +++ b/apps/frontend/src/App.jsx @@ -8,6 +8,8 @@ import AlertsView from './views/AlertsView'; import ProfileView from './views/ProfileView'; import AdminView from './views/AdminView'; import LoginModal from './components/LoginModal'; +import ForgotPasswordModal from './components/ForgotPasswordModal'; +import ResetPasswordView from './views/ResetPasswordView'; import SavedNotifications from './components/SavedNotifications'; import BottomNav from './components/BottomNav'; import { getFaro } from './utils/faro'; @@ -17,6 +19,9 @@ function App() { const [currentUser, setCurrentUser] = useState(null); const [authChecked, setAuthChecked] = useState(false); const [showLogin, setShowLogin] = useState(false); + const [showForgot, setShowForgot] = useState(false); + const [forgotMode, setForgotMode] = useState('password'); + const [resetToken, setResetToken] = useState(null); const [showSaved, setShowSaved] = useState(false); const [badgeCount, setBadgeCount] = useState(0); const [prescriptionSearch, setPrescriptionSearch] = useState(''); @@ -67,6 +72,13 @@ function App() { }); }; + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + if (token) { + setResetToken(token); + setScreen('reset-password'); + } + fetch('/api/auth/check') .then(r => r.json()) .then(data => { if (data.authenticated) setCurrentUser(data.user); }) @@ -126,6 +138,18 @@ function App() { setCurrentUser(prev => ({ ...prev, ...updated })); } + function handleForgotPassword(mode) { + setForgotMode(mode); + setShowLogin(false); + setShowForgot(true); + } + + function handleResetComplete() { + setResetToken(null); + setShowLogin(true); + window.history.replaceState({}, '', '/'); + } + function handleAdminClick() { setScreen('admin'); } @@ -211,6 +235,14 @@ function App() { /> ); break; + case 'reset-password': + activeView = ( + + ); + break; case 'admin': activeView = ; break; @@ -243,6 +275,18 @@ function App() { setShowLogin(false)} + onForgotPassword={handleForgotPassword} + /> + )} + + {showForgot && ( + setShowForgot(false)} + onBackToLogin={() => { + setShowForgot(false); + setShowLogin(true); + }} /> )} diff --git a/apps/frontend/src/components/ForgotPasswordModal.css b/apps/frontend/src/components/ForgotPasswordModal.css new file mode 100644 index 0000000..33e699b --- /dev/null +++ b/apps/frontend/src/components/ForgotPasswordModal.css @@ -0,0 +1,77 @@ +.forgot-box { + max-width: 380px; +} + +.forgot-back { + background: none; + border: none; + color: var(--primary); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + padding: 0; + margin-bottom: 1rem; + display: inline-block; +} + +.forgot-back:hover { + opacity: 0.8; +} + +.forgot-options { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.forgot-option { + display: flex; + align-items: center; + gap: 0.85rem; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 4px); + padding: 1rem; + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; + font-size: 0.9rem; + color: var(--text-main); +} + +.forgot-option:hover { + border-color: var(--primary); + background: var(--surface); +} + +.forgot-option-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.forgot-option-text { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.forgot-option-desc { + font-size: 0.8rem; + color: var(--text-muted); + font-weight: 400; +} + +.forgot-sent-icon { + text-align: center; + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + +.forgot-sent-text { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.5; + text-align: center; + margin: 0 0 0.5rem; +} diff --git a/apps/frontend/src/components/ForgotPasswordModal.jsx b/apps/frontend/src/components/ForgotPasswordModal.jsx new file mode 100644 index 0000000..d01d09d --- /dev/null +++ b/apps/frontend/src/components/ForgotPasswordModal.jsx @@ -0,0 +1,159 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from '../i18n'; +import './LoginModal.css'; +import './ForgotPasswordModal.css'; + +function ForgotPasswordModal({ onClose, onBackToLogin, initialMode }) { + const { t } = useTranslation(); + const [step, setStep] = useState(initialMode ? 'email' : 'choose'); // choose | email | sent + const [mode, setMode] = useState(initialMode || ''); // username | password + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const emailRef = useRef(null); + + useEffect(() => { + emailRef.current?.focus(); + function handleKey(e) { if (e.key === 'Escape') onClose(); } + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [onClose]); + + function handleChoose(m) { + setMode(m); + setStep('email'); + setError(''); + } + + async function handleSubmit(e) { + e.preventDefault(); + const em = email.trim(); + if (!em) return; + setLoading(true); + setError(''); + try { + const endpoint = mode === 'password' + ? '/api/auth/forgot-password' + : '/api/auth/forgot-username'; + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: em }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error || t('forgot.error')); + return; + } + setStep('sent'); + } catch { + setError(t('login.networkError')); + } finally { + setLoading(false); + } + } + + function handleBack() { + if (step === 'email') setStep('choose'); + else onBackToLogin(); + } + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="forgot-title" + > + {step === 'choose' && ( + <> + +

{t('forgot.title')}

+

{t('forgot.subtitle')}

+
+ + +
+ + )} + + {step === 'email' && ( + <> + +

+ {mode === 'password' ? t('forgot.resetTitle') : t('forgot.usernameTitle')} +

+

+ {mode === 'password' ? t('forgot.resetDesc') : t('forgot.usernameDesc2')} +

+
+
+ + setEmail(e.target.value)} + autoComplete="email" + ref={emailRef} + disabled={loading} + placeholder={t('forgot.emailPlaceholder')} + /> +
+ {error &&

{error}

} +
+ + +
+
+ + )} + + {step === 'sent' && ( + <> +
✅
+

{t('forgot.sentTitle')}

+

{t('forgot.sentText')}

+
+ +
+ + )} +
+
+ ); +} + +export default ForgotPasswordModal; diff --git a/apps/frontend/src/components/LoginModal.css b/apps/frontend/src/components/LoginModal.css index 6872d14..243f15f 100644 --- a/apps/frontend/src/components/LoginModal.css +++ b/apps/frontend/src/components/LoginModal.css @@ -171,3 +171,27 @@ opacity: 0.5; cursor: not-allowed; } + +.forgot-links { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + margin-top: 1rem; +} + +.forgot-link { + background: none; + border: none; + color: var(--text-muted); + font-size: 0.8rem; + cursor: pointer; + padding: 0.2rem 0; + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.15s; +} + +.forgot-link:hover { + color: var(--primary); +} diff --git a/apps/frontend/src/components/LoginModal.jsx b/apps/frontend/src/components/LoginModal.jsx index 8be5e12..e7ca293 100644 --- a/apps/frontend/src/components/LoginModal.jsx +++ b/apps/frontend/src/components/LoginModal.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from '../i18n'; import './LoginModal.css'; -function LoginModal({ onLogin, onClose, initialMode = 'login' }) { +function LoginModal({ onLogin, onClose, initialMode = 'login', onForgotPassword }) { const { t } = useTranslation(); const [mode, setMode] = useState(initialMode === 'register' ? 'register' : 'login'); const [username, setUsername] = useState(''); @@ -148,6 +148,24 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) { + {!isRegister && ( +
+ + +
+ )} ); diff --git a/apps/frontend/src/i18n/locales/ca.js b/apps/frontend/src/i18n/locales/ca.js index d204f79..b6d8b15 100644 --- a/apps/frontend/src/i18n/locales/ca.js +++ b/apps/frontend/src/i18n/locales/ca.js @@ -251,6 +251,45 @@ const ca = { 'profile.languageCatalan': 'Català', 'profile.languageSpanish': 'Castellà', + // ForgotPasswordModal + 'forgot.title': 'Problemes per accedir?', + 'forgot.subtitle': 'Tria què necessites recuperar', + 'forgot.password': 'He oblidat la meva contrasenya', + 'forgot.passwordDesc': 'T\'enviarem un enllaç per crear-ne una de nova', + 'forgot.username': 'He oblidat el meu usuari', + 'forgot.usernameDesc': 'T\'enviarem el teu nom d\'usuari', + 'forgot.resetTitle': 'Restablir contrasenya', + 'forgot.resetDesc': 'Introdueix el teu correu electrònic i t\'enviarem un enllaç per crear una nova contrasenya.', + 'forgot.usernameTitle': 'Recuperar usuari', + 'forgot.usernameDesc2': 'Introdueix el teu correu electrònic i t\'enviarem el teu nom d\'usuari.', + 'forgot.email': 'Correu electrònic', + 'forgot.emailPlaceholder': 'el_teu@email.com', + 'forgot.send': 'Enviar', + 'forgot.sending': 'Enviant...', + 'forgot.cancel': 'Cancel·lar', + 'forgot.back': 'Tornar', + 'forgot.backToLogin': 'Tornar a iniciar sessió', + 'forgot.sentTitle': 'Correu enviat', + 'forgot.sentText': 'Si l\'adreça de correu existeix a la nostra base de dades, rebràs un missatge en uns minuts. Revisa la teva safata d\'entrada.', + 'forgot.close': 'Tancar', + 'forgot.error': 'Error en processar la sol·licitud', + 'forgot.forgotPassword': 'Has oblidat la contrasenya?', + 'forgot.forgotUsername': 'Has oblidat l\'usuari?', + + // ResetPasswordView + 'reset.title': 'Crear nova contrasenya', + 'reset.subtitle': 'Introdueix la teva nova contrasenya', + 'reset.newPassword': 'Nova contrasenya', + 'reset.confirmPassword': 'Confirmar contrasenya', + 'reset.saveBtn': 'Canviar contrasenya', + 'reset.saving': 'Desant...', + 'reset.passwordsDontMatch': 'Les contrasenyes no coincideixen', + 'reset.successTitle': 'Contrasenya actualitzada', + 'reset.successText': 'La teva contrasenya s\'ha actualitzat correctament. Ja pots iniciar sessió amb la teva nova contrasenya.', + 'reset.goToLogin': 'Anar a iniciar sessió', + 'reset.error': 'Error en restablir la contrasenya', + 'reset.invalidToken': 'Enllaç invàlid o caducat. Sol·licita un nou restabliment de contrasenya.', + // SavedNotifications 'savedNotifications.title': '🔔 Notificacions Desades', 'savedNotifications.close': 'Tancar', diff --git a/apps/frontend/src/i18n/locales/es.js b/apps/frontend/src/i18n/locales/es.js index 860ca6c..b93fe61 100644 --- a/apps/frontend/src/i18n/locales/es.js +++ b/apps/frontend/src/i18n/locales/es.js @@ -251,6 +251,45 @@ const es = { 'profile.languageCatalan': 'Català', 'profile.languageSpanish': 'Castellano', + // ForgotPasswordModal + 'forgot.title': '¿Problemas para acceder?', + 'forgot.subtitle': 'Elige qué necesitas recuperar', + 'forgot.password': 'He olvidado mi contraseña', + 'forgot.passwordDesc': 'Te enviaremos un enlace para crear una nueva', + 'forgot.username': 'He olvidado mi usuario', + 'forgot.usernameDesc': 'Te enviaremos tu nombre de usuario', + 'forgot.resetTitle': 'Restablecer contraseña', + 'forgot.resetDesc': 'Introduce tu correo electrónico y te enviaremos un enlace para crear una nueva contraseña.', + 'forgot.usernameTitle': 'Recuperar usuario', + 'forgot.usernameDesc2': 'Introduce tu correo electrónico y te enviaremos tu nombre de usuario.', + 'forgot.email': 'Correo electrónico', + 'forgot.emailPlaceholder': 'tu@email.com', + 'forgot.send': 'Enviar', + 'forgot.sending': 'Enviando...', + 'forgot.cancel': 'Cancelar', + 'forgot.back': 'Volver', + 'forgot.backToLogin': 'Volver a iniciar sesión', + 'forgot.sentTitle': 'Correo enviado', + 'forgot.sentText': 'Si la dirección de correo existe en nuestra base de datos, recibirás un mensaje en unos minutos. Revisa tu bandeja de entrada.', + 'forgot.close': 'Cerrar', + 'forgot.error': 'Error al procesar la solicitud', + 'forgot.forgotPassword': '¿Olvidaste tu contraseña?', + 'forgot.forgotUsername': '¿Olvidaste tu usuario?', + + // ResetPasswordView + 'reset.title': 'Crear nueva contraseña', + 'reset.subtitle': 'Introduce tu nueva contraseña', + 'reset.newPassword': 'Nueva contraseña', + 'reset.confirmPassword': 'Confirmar contraseña', + 'reset.saveBtn': 'Cambiar contraseña', + 'reset.saving': 'Guardando...', + 'reset.passwordsDontMatch': 'Las contraseñas no coinciden', + 'reset.successTitle': 'Contraseña actualizada', + 'reset.successText': 'Tu contraseña se ha actualizado correctamente. Ya puedes iniciar sesión con tu nueva contraseña.', + 'reset.goToLogin': 'Ir a iniciar sesión', + 'reset.error': 'Error al restablecer la contraseña', + 'reset.invalidToken': 'Enlace inválido o expirado. Solicita un nuevo restablecimiento de contraseña.', + // SavedNotifications 'savedNotifications.title': '🔔 Notificaciones Guardadas', 'savedNotifications.close': 'Cerrar', diff --git a/apps/frontend/src/views/ResetPasswordView.css b/apps/frontend/src/views/ResetPasswordView.css new file mode 100644 index 0000000..8784840 --- /dev/null +++ b/apps/frontend/src/views/ResetPasswordView.css @@ -0,0 +1,100 @@ +.reset-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 1.5rem; +} + +.reset-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem 2.25rem 1.75rem; + width: 100%; + max-width: 360px; + box-shadow: 0 20px 60px rgba(28, 25, 23, 0.12); +} + +.reset-card h2 { + margin: 0 0 0.25rem; + font-size: 1.35rem; + font-weight: 700; + color: var(--text-main); + text-align: center; +} + +.reset-sub { + margin: 0 0 1.5rem; + font-size: 0.875rem; + color: var(--text-muted); + text-align: center; +} + +.reset-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; +} + +.reset-field label { + font-size: 0.82rem; + font-weight: 600; + color: var(--text-muted); +} + +.reset-field input { + padding: 0.6rem 0.85rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 4px); + background: var(--surface-muted); + color: var(--text-main); + font-size: 0.95rem; + outline: none; + transition: border-color 0.15s; +} + +.reset-field input:focus { + border-color: var(--primary); +} + +.reset-field input:disabled { + opacity: 0.6; +} + +.reset-error { + font-size: 0.82rem; + color: var(--error); + margin: 0.25rem 0 0.75rem; + text-align: center; +} + +.reset-btn { + width: 100%; + background: var(--primary); + border: none; + color: var(--on-primary); + padding: 0.65rem 1.35rem; + border-radius: 999px; + cursor: pointer; + font-size: 0.95rem; + font-weight: 600; + transition: opacity 0.15s; + margin-top: 0.5rem; +} + +.reset-btn:hover:not(:disabled) { + opacity: 0.88; +} + +.reset-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.reset-icon { + text-align: center; + font-size: 2.5rem; + margin-bottom: 0.75rem; +} diff --git a/apps/frontend/src/views/ResetPasswordView.jsx b/apps/frontend/src/views/ResetPasswordView.jsx new file mode 100644 index 0000000..298eb4d --- /dev/null +++ b/apps/frontend/src/views/ResetPasswordView.jsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from '../i18n'; +import './ResetPasswordView.css'; + +function ResetPasswordView({ token, onReset }) { + const { t } = useTranslation(); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!token) setError(t('reset.invalidToken')); + }, [token, t]); + + async function handleSubmit(e) { + e.preventDefault(); + if (password.length < 8) { + setError(t('login.passwordError')); + return; + } + if (password !== confirm) { + setError(t('reset.passwordsDontMatch')); + return; + } + setLoading(true); + setError(''); + try { + const res = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error || t('reset.error')); + return; + } + setSuccess(true); + } catch { + setError(t('login.networkError')); + } finally { + setLoading(false); + } + } + + if (success) { + return ( +
+
+
✅
+

{t('reset.successTitle')}

+

{t('reset.successText')}

+ +
+
+ ); + } + + return ( +
+
+

{t('reset.title')}

+

{t('reset.subtitle')}

+
+
+ + setPassword(e.target.value)} + autoComplete="new-password" + disabled={loading} + minLength={8} + placeholder="········" + /> +
+
+ + setConfirm(e.target.value)} + autoComplete="new-password" + disabled={loading} + minLength={8} + placeholder="········" + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} + +export default ResetPasswordView; diff --git a/docker-compose.yml b/docker-compose.yml index 7c5a59e..f912bd3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -152,6 +152,18 @@ services: networks: - internal + # --- Mailpit — SMTP server for development (captures all outgoing emails) --- + # Web UI: http://localhost:8025 + # SMTP: smtp://localhost:1025 (no auth) + mailpit: + image: axllent/mailpit:latest + restart: unless-stopped + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP + networks: + - internal + # --- MongoDB for Parapharmacy --- mongodb: image: mongo:7 diff --git a/package-lock.json b/package-lock.json index bd2260d..d11186d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,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", @@ -22421,6 +22422,14 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",