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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
|
||||
184
os-league-tools-master/src/pages/ResetPassword.js
Normal file
184
os-league-tools-master/src/pages/ResetPassword.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user