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
@@ -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 (
<div className="reset-container">
<div className="reset-card">
<div className="reset-icon">✅</div>
<h2>{t('reset.successTitle')}</h2>
<p>{t('reset.successText')}</p>
<button type="button" className="reset-btn" onClick={onReset}>
{t('reset.goToLogin')}
</button>
</div>
</div>
);
}
return (
<div className="reset-container">
<div className="reset-card">
<h2>{t('reset.title')}</h2>
<p className="reset-sub">{t('reset.subtitle')}</p>
<form onSubmit={handleSubmit} noValidate>
<div className="reset-field">
<label htmlFor="reset-password">{t('reset.newPassword')}</label>
<input
id="reset-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
disabled={loading}
minLength={8}
placeholder="········"
/>
</div>
<div className="reset-field">
<label htmlFor="reset-confirm">{t('reset.confirmPassword')}</label>
<input
id="reset-confirm"
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
autoComplete="new-password"
disabled={loading}
minLength={8}
placeholder="········"
/>
</div>
{error && <p className="reset-error">{error}</p>}
<button
type="submit"
className="reset-btn"
disabled={loading || !password || !confirm}
>
{loading ? t('reset.saving') : t('reset.saveBtn')}
</button>
</form>
</div>
</div>
);
}
export default ResetPasswordView;