feat: add password reset functionality and email notifications

- Implemented forgot password and reset password routes in the backend.
- Added email sending capabilities using Nodemailer for password reset requests.
- Created ResetPassword page in the frontend for users to reset their passwords.
- Updated user model to include reset token and expiry fields.
- Integrated hiscores API with caching mechanism for improved performance.
- Enhanced authentication modal to include forgot password option.
- Updated environment configuration for SMTP settings.
This commit is contained in:
2026-02-03 23:37:47 +00:00
parent 3cec7abee9
commit 95063d4066
17 changed files with 945 additions and 21 deletions

View File

@@ -20,6 +20,7 @@ import ViewCharacter from './pages/ViewCharacter';
import Groups from './pages/Groups';
import Planner from './pages/Planner';
import Admin from './pages/Admin';
import ResetPassword from './pages/ResetPassword';
import { submitRenderError } from './client/feedback-client';
import { ErrorPage } from './components/common/util/ErrorBoundary';
@@ -114,6 +115,7 @@ export default function App() {
<Route path='settings' element={<Settings />} />
<Route path='faq' element={<Faq />} />
<Route path='admin' element={<Admin />} />
<Route path='reset-password' element={<ResetPassword />} />
</Routes>
</ErrorBoundary>
</Auth0Provider>

View File

@@ -73,3 +73,25 @@ export function getCurrentUser() {
.then(handleResponse)
.catch(handleError);
}
export function forgotPassword(email) {
return fetch(`${BASE_URL}/api/forgot-password`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ email }),
})
.then(handleResponse)
.catch(handleError);
}
export function resetPassword(token, password) {
return fetch(`${BASE_URL}/api/reset-password`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ token, password }),
})
.then(handleResponse)
.catch(handleError);
}

View File

@@ -1,4 +1,5 @@
const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
export function submitBug(formData) {
return submitFeedback(formData, '/bug');

View File

@@ -3,7 +3,8 @@
* Communicates with the Java/Spring Boot backend at spring-backend/
*/
const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || 'http://localhost:8080';
// Use relative URLs when REACT_APP_GROUP_IRONMEN_URL is not set (same-origin via nginx)
const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || '';
const DEFAULT_HEADERS = {
'Content-type': 'application/json',
};

View File

@@ -9,7 +9,7 @@ export default async function getHiscores(rsn, handleResultCallback) {
});
}
const url = `${BASE_URL}/hiscores/${rsn}`;
const url = `${BASE_URL}/api/hiscores/${rsn}`;
await fetch(url)
.then(res => res.json())
.then(

View File

@@ -1,4 +1,5 @@
const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
const DEFAULT_HEADERS = {
'Content-type': 'application/json',
};

View File

@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import Modal from './Modal';
import Spinner from './common/Spinner';
import { login, register } from '../client/auth-client';
import { login, register, forgotPassword } from '../client/auth-client';
const VIEW = {
LOGIN: 'login',
REGISTER: 'register',
FORGOT_PASSWORD: 'forgot_password',
};
export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
@@ -21,6 +22,9 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
const [registerPassword, setRegisterPassword] = useState('');
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
const [forgotEmail, setForgotEmail] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const resetForm = () => {
setLoginUsername('');
setLoginPassword('');
@@ -28,7 +32,9 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
setRegisterEmail('');
setRegisterPassword('');
setRegisterConfirmPassword('');
setForgotEmail('');
setError('');
setSuccessMessage('');
};
const handleClose = () => {
@@ -99,6 +105,28 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
}
};
const handleForgotPassword = async e => {
e.preventDefault();
setError('');
setSuccessMessage('');
if (!forgotEmail) {
setError('Please enter your email address');
return;
}
setIsLoading(true);
const result = await forgotPassword(forgotEmail);
setIsLoading(false);
if (result.success) {
setSuccessMessage(result.value.message);
setForgotEmail('');
} else {
setError(result.error || 'Failed to send reset email');
}
};
return (
<Modal
isOpen={isOpen}
@@ -107,11 +135,13 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
className='w-96 shadow shadow-primary rounded-md bg-primary-alt'
>
<Modal.Header className='text-center small-caps tracking-wide text-xl text-accent font-semibold'>
{view === VIEW.LOGIN ? 'Login' : 'Create Account'}
{view === VIEW.LOGIN && 'Login'}
{view === VIEW.REGISTER && 'Create Account'}
{view === VIEW.FORGOT_PASSWORD && 'Reset Password'}
</Modal.Header>
<Modal.Body className='text-primary text-sm'>
{view === VIEW.LOGIN ? (
{view === VIEW.LOGIN && (
<form onSubmit={handleLogin} className='m-4 flex flex-col gap-3'>
<label htmlFor='login-username' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Username</span>
@@ -154,8 +184,18 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
'Login'
)}
</button>
<button
type='button'
className='text-accent text-xs hover:underline'
onClick={() => switchView(VIEW.FORGOT_PASSWORD)}
>
Forgot password?
</button>
</form>
) : (
)}
{view === VIEW.REGISTER && (
<form onSubmit={handleRegister} className='m-4 flex flex-col gap-3'>
<label htmlFor='register-username' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Username</span>
@@ -230,17 +270,54 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
</button>
</form>
)}
{view === VIEW.FORGOT_PASSWORD && (
<form onSubmit={handleForgotPassword} className='m-4 flex flex-col gap-3'>
<p className='text-secondary text-xs'>
Enter your email address and we&apos;ll send you a link to reset your password.
</p>
<label htmlFor='forgot-email' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Email</span>
<input
id='forgot-email'
name='email'
type='email'
className='input-primary text-sm form-input'
placeholder='Enter your email'
value={forgotEmail}
onChange={e => setForgotEmail(e.target.value)}
disabled={isLoading}
autoComplete='email'
/>
</label>
{error && <div className='text-error text-xs text-center'>{error}</div>}
{successMessage && <div className='text-success text-xs text-center'>{successMessage}</div>}
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
{isLoading ? (
<span className='flex items-center justify-center gap-2'>
<Spinner size={Spinner.SIZE.sm} /> Sending...
</span>
) : (
'Send Reset Link'
)}
</button>
</form>
)}
</Modal.Body>
<Modal.Footer className='text-center text-sm'>
{view === VIEW.LOGIN ? (
{view === VIEW.LOGIN && (
<p className='text-secondary py-2'>
Don&apos;t have an account?{' '}
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.REGISTER)}>
Register
</button>
</p>
) : (
)}
{view === VIEW.REGISTER && (
<p className='text-secondary py-2'>
Already have an account?{' '}
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.LOGIN)}>
@@ -248,6 +325,14 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
</button>
</p>
)}
{view === VIEW.FORGOT_PASSWORD && (
<p className='text-secondary py-2'>
Remember your password?{' '}
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.LOGIN)}>
Login
</button>
</p>
)}
</Modal.Footer>
</Modal>
);

View File

@@ -0,0 +1,184 @@
import React, { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import Card from '../components/common/Card';
import PageWrapper from '../components/PageWrapper';
import Spinner from '../components/common/Spinner';
import { resetPassword } from '../client/auth-client';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setIsLoading(true);
const result = await resetPassword(token, password);
setIsLoading(false);
if (result.success) {
setSuccess(true);
} else {
setError(result.error || 'Failed to reset password');
}
};
// No token provided
if (!token) {
return (
<PageWrapper>
<div className='container lg:max-w-[500px] mx-auto mt-8'>
<Card>
<Card.Header>
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
Invalid Reset Link
</p>
</Card.Header>
<Card.Body>
<p className='text-center text-secondary text-sm p-4'>
This password reset link is invalid or has expired.
Please request a new password reset from the login page.
</p>
<div className='flex justify-center pb-4'>
<button
type='button'
className='button-filled px-6 py-2'
onClick={() => navigate('/')}
>
Go to Homepage
</button>
</div>
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}
// Success state
if (success) {
return (
<PageWrapper>
<div className='container lg:max-w-[500px] mx-auto mt-8'>
<Card>
<Card.Header>
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
Password Reset
</p>
</Card.Header>
<Card.Body>
<div className='p-4 text-center'>
<p className='text-success text-sm mb-4'>
Your password has been reset successfully!
</p>
<p className='text-secondary text-sm mb-4'>
You can now log in with your new password.
</p>
<button
type='button'
className='button-filled px-6 py-2'
onClick={() => navigate('/')}
>
Go to Homepage
</button>
</div>
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}
// Reset form
return (
<PageWrapper>
<div className='container lg:max-w-[500px] mx-auto mt-8'>
<Card>
<Card.Header>
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
Reset Password
</p>
</Card.Header>
<Card.Body>
<form onSubmit={handleSubmit} className='p-4 flex flex-col gap-4'>
<p className='text-secondary text-sm text-center'>
Enter your new password below.
</p>
<label htmlFor='new-password' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>New Password</span>
<input
id='new-password'
name='new-password'
type='password'
className='input-primary text-sm form-input'
placeholder='Enter new password'
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
autoComplete='new-password'
/>
</label>
<label htmlFor='confirm-password' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Confirm Password</span>
<input
id='confirm-password'
name='confirm-password'
type='password'
className='input-primary text-sm form-input'
placeholder='Confirm new password'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
autoComplete='new-password'
/>
</label>
{error && (
<div className='text-error text-xs text-center'>{error}</div>
)}
<button
type='submit'
className='button-filled py-2'
disabled={isLoading}
>
{isLoading ? (
<span className='flex items-center justify-center gap-2'>
<Spinner size={Spinner.SIZE.sm} /> Resetting...
</span>
) : (
'Reset Password'
)}
</button>
</form>
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}