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:
142
CLAUDE.md
Normal file
142
CLAUDE.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Leagues Tools
|
||||
|
||||
OSRS (Old School RuneScape) League tracker and planning application. Allows users to track League tasks, unlocks, planning, and group ironman progress via RuneLite plugin integration.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend (`os-league-tools-master/`)
|
||||
- **React 18.3** with React Router DOM 6.28
|
||||
- **Redux Toolkit** for state management
|
||||
- **TailwindCSS 3.0** for styling
|
||||
- **Webpack 5** for bundling
|
||||
|
||||
### Backend (`server/`)
|
||||
- **Hono** - lightweight web framework on Node.js
|
||||
- **TypeScript** (ES2022 target)
|
||||
- **Prisma 6.2** ORM with **SQLite** database
|
||||
- **bcrypt** for password hashing
|
||||
- **Nodemailer** for emails
|
||||
- **Blake2b** for token hashing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
leagues-tools-dev/
|
||||
├── os-league-tools-master/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── App.js # Main entry, routing
|
||||
│ │ ├── client/ # API client modules
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── store/ # Redux slices
|
||||
│ │ └── hooks/ # Custom hooks
|
||||
│ └── build/ # Production build output
|
||||
│
|
||||
├── server/ # Hono backend
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Server entry point
|
||||
│ │ ├── app.ts # Hono app setup
|
||||
│ │ ├── db.ts # Prisma client
|
||||
│ │ ├── routes/ # API route handlers
|
||||
│ │ ├── middleware/ # Auth middleware
|
||||
│ │ └── utils/ # Helpers (email, password, blake2)
|
||||
│ └── prisma/schema.prisma # Database schema
|
||||
│
|
||||
└── ecosystem.config.js # PM2 deployment config
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd os-league-tools-master
|
||||
npm run dev # Start dev server (port 3000)
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd server
|
||||
npm run dev # Start with hot reload (tsx watch)
|
||||
npm run build # Compile TypeScript
|
||||
npm run db:push # Push schema to database
|
||||
npm run db:migrate # Run migrations
|
||||
npm run db:generate # Generate Prisma client
|
||||
```
|
||||
|
||||
### PM2 (Root)
|
||||
```bash
|
||||
npm run start # Start all PM2 apps
|
||||
npm run stop # Stop all apps
|
||||
npm run logs # View logs
|
||||
```
|
||||
|
||||
## Database Schema (Key Models)
|
||||
|
||||
- **User** - Auth accounts with role (USER/ADMIN), sessions, characters
|
||||
- **Character** - User's OSRS characters with RSN, stores tasks/unlocks/notes as JSON
|
||||
- **Session** - Cookie-based sessions (7-day TTL)
|
||||
- **Group** - Group Ironmen tracking with Blake2b hashed token
|
||||
- **Member** - Group member data (stats, inventory, equipment, bank, quests)
|
||||
- **HiscoresCache** - Cached OSRS hiscores (5-min TTL)
|
||||
|
||||
## API Routes
|
||||
|
||||
All routes prefixed with `/api`:
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/register`, `/login`, `/logout` | Auth |
|
||||
| `/me`, `/auth/status` | Current user |
|
||||
| `/forgot-password`, `/reset-password` | Password reset |
|
||||
| `/characters` | User character CRUD |
|
||||
| `/group/:name/*` | Group data (RuneLite plugin) |
|
||||
| `/hiscores/:rsn` | OSRS hiscores with caching |
|
||||
| `/admin/*` | Admin user management |
|
||||
| `/create-group`, `/ge-prices` | Public endpoints |
|
||||
|
||||
## Authentication
|
||||
|
||||
- **Session-based**: HTTP-only secure cookies, 7-day TTL
|
||||
- **Group tokens**: Blake2b-256 hashed, used by RuneLite plugin
|
||||
- **Roles**: USER (default), ADMIN (access to `/api/admin/*`)
|
||||
|
||||
Middleware in `server/src/middleware/`:
|
||||
- `session.ts` - requireAuth, requireAdmin
|
||||
- `groupAuth.ts` - RuneLite token validation
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Backend expects (via `.env` or ecosystem.config.js):
|
||||
- `PORT` - Server port (3001 default)
|
||||
- `DATABASE_URL` - SQLite path (`file:./data.db`)
|
||||
- `CORS_ORIGINS` - Allowed origins (comma-separated)
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM` - Email config
|
||||
- `FRONTEND_BUILD_PATH` - Path to React build
|
||||
|
||||
Frontend uses:
|
||||
- `REACT_APP_RELDO_URL` - Override API endpoint
|
||||
- `REACT_APP_GA_MID` - Google Analytics ID
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **JSON Storage**: Complex data (tasks, unlocks) stored as JSON strings in SQLite
|
||||
2. **Graceful Fallbacks**: Hiscores returns stale cache if OSRS API fails
|
||||
3. **Character Active State**: Only one character active per user
|
||||
4. **Bulk Sync**: Merges local client data with server on login
|
||||
5. **SPA Routing**: Backend serves `index.html` for non-API routes
|
||||
|
||||
## Ports
|
||||
|
||||
| Service | Dev | Prod |
|
||||
|---------|-----|------|
|
||||
| Frontend | 3000 | (served by backend) |
|
||||
| Backend | 3003 | 3002 |
|
||||
|
||||
## Important Files
|
||||
|
||||
- `server/src/app.ts` - All route mounting, CORS, middleware
|
||||
- `server/prisma/schema.prisma` - Full database schema
|
||||
- `os-league-tools-master/src/App.js` - Frontend routing
|
||||
- `os-league-tools-master/src/client/` - API client functions
|
||||
- `ecosystem.config.js` - PM2 deployment configuration
|
||||
@@ -13,6 +13,14 @@ module.exports = {
|
||||
BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f',
|
||||
CORS_ORIGINS: 'https://leagues.tools,https://www.leagues.tools,http://localhost:3002',
|
||||
FRONTEND_BUILD_PATH: '../os-league-tools-master/build',
|
||||
APP_URL: 'https://leagues.tools',
|
||||
// SMTP Configuration - fill in your server details
|
||||
SMTP_HOST: 'yeahnah-net.mail.protection.outlook.com', // e.g., 'smtp.gmail.com' or 'mail.yourserver.com'
|
||||
SMTP_PORT: '25', // 587 for TLS, 465 for SSL
|
||||
SMTP_SECURE: 'false', // 'true' for port 465, 'false' for 587
|
||||
SMTP_USER: 'bailey@yeahnah.net', // SMTP username/email
|
||||
SMTP_PASS: 'Howaboutno123!', // SMTP password or app password
|
||||
SMTP_FROM: 'noreply@leagues.tools', // From address for emails
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -28,6 +36,14 @@ module.exports = {
|
||||
BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f',
|
||||
CORS_ORIGINS: 'http://localhost:3000,http://localhost:3001,https://dev.leagues.tools',
|
||||
FRONTEND_BUILD_PATH: '../os-league-tools-master/build',
|
||||
APP_URL: 'https://dev.leagues.tools',
|
||||
// SMTP Configuration - same as prod or leave empty for console logging
|
||||
SMTP_HOST: 'yeahnah-net.mail.protection.outlook.com',
|
||||
SMTP_PORT: '25',
|
||||
SMTP_SECURE: 'false',
|
||||
SMTP_USER: 'bailey@yeahnah.net',
|
||||
SMTP_PASS: 'Howaboutno123!',
|
||||
SMTP_FROM: 'noreply@leagues.tools',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -43,3 +59,5 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
21
server/package-lock.json
generated
21
server/package-lock.json
generated
@@ -13,11 +13,13 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"blakejs": "^1.2.1",
|
||||
"hono": "^4.6.16",
|
||||
"nodemailer": "^7.0.13",
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^6.2.1",
|
||||
"tsx": "^4.19.2",
|
||||
@@ -610,6 +612,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||
"integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -1273,6 +1285,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
|
||||
@@ -19,14 +19,16 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@prisma/client": "^6.2.1",
|
||||
"blakejs": "^1.2.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"blakejs": "^1.2.1",
|
||||
"hono": "^4.6.16",
|
||||
"nodemailer": "^7.0.13",
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^6.2.1",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -15,16 +15,18 @@ enum Role {
|
||||
|
||||
// User authentication
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role Role @default(USER)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
characters Character[]
|
||||
ownedGroups Group[] @relation("GroupOwner")
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role Role @default(USER)
|
||||
resetToken String? // Password reset token
|
||||
resetTokenExpiry DateTime? // Token expiration time
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
characters Character[]
|
||||
ownedGroups Group[] @relation("GroupOwner")
|
||||
}
|
||||
|
||||
// User's OSRS characters (for task/unlock tracking)
|
||||
@@ -213,3 +215,16 @@ model CollectionLogNew {
|
||||
|
||||
@@id([memberId, pageId])
|
||||
}
|
||||
|
||||
// Hiscores cache - stores fetched hiscores data
|
||||
model HiscoresCache {
|
||||
rsn String @id // RuneScape Name (lowercase for lookup)
|
||||
displayRsn String // Original case RSN for display
|
||||
skills String // JSON - skill data
|
||||
clues String // JSON - clue scroll data
|
||||
activities String // JSON - activities/bosses data
|
||||
leaguePoints Int @default(0)
|
||||
fetchedAt DateTime @default(now())
|
||||
|
||||
@@index([fetchedAt])
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import groupRoutes from './routes/groups';
|
||||
import memberRoutes from './routes/members';
|
||||
import adminRoutes from './routes/admin';
|
||||
import characterRoutes from './routes/characters';
|
||||
import hiscoresRoutes from './routes/hiscores';
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
@@ -46,6 +47,9 @@ export function createApp() {
|
||||
// Health check
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
// Hiscores proxy - under /api so nginx routes correctly
|
||||
app.route('/api/hiscores', hiscoresRoutes);
|
||||
|
||||
// Serve static files from React build
|
||||
const frontendPath = process.env.FRONTEND_BUILD_PATH || '../os-league-tools-master/build';
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { prisma } from '../db';
|
||||
import { hashPassword, verifyPassword } from '../utils/password';
|
||||
import { createSession, destroySession, requireAuth } from '../middleware/session';
|
||||
import { sendPasswordResetEmail, isEmailConfigured } from '../utils/email';
|
||||
|
||||
const auth = new Hono();
|
||||
|
||||
@@ -132,4 +134,100 @@ auth.get('/me', requireAuth, (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/forgot-password
|
||||
* Request a password reset
|
||||
*/
|
||||
auth.post('/forgot-password', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
return c.json({ error: 'Email is required' }, 400);
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user) {
|
||||
return c.json({ message: 'If an account with that email exists, a reset link has been sent.' });
|
||||
}
|
||||
|
||||
// Generate secure token
|
||||
const resetToken = randomBytes(32).toString('hex');
|
||||
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||
|
||||
// Store token in database
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetToken,
|
||||
resetTokenExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email with reset link
|
||||
const baseUrl = process.env.APP_URL || 'https://leagues.tools';
|
||||
|
||||
if (isEmailConfigured()) {
|
||||
const emailResult = await sendPasswordResetEmail(email, resetToken, baseUrl);
|
||||
if (!emailResult.success) {
|
||||
console.error(`Failed to send password reset email to ${email}:`, emailResult.error);
|
||||
}
|
||||
} else {
|
||||
// Log token in development when email is not configured
|
||||
console.log(`Password reset requested for ${email}. Token: ${resetToken}`);
|
||||
console.log(`Reset URL: ${baseUrl}/reset-password?token=${resetToken}`);
|
||||
}
|
||||
|
||||
return c.json({ message: 'If an account with that email exists, a reset link has been sent.' });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/reset-password
|
||||
* Reset password using token
|
||||
*/
|
||||
auth.post('/reset-password', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { token, password } = body;
|
||||
|
||||
if (!token || !password) {
|
||||
return c.json({ error: 'Token and password are required' }, 400);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
// Find user with this token
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: {
|
||||
gt: new Date(), // Token must not be expired
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Invalid or expired reset token' }, 400);
|
||||
}
|
||||
|
||||
// Update password and clear token
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ message: 'Password has been reset successfully. You can now log in.' });
|
||||
});
|
||||
|
||||
export default auth;
|
||||
|
||||
205
server/src/routes/hiscores.ts
Normal file
205
server/src/routes/hiscores.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Hono } from 'hono';
|
||||
import { prisma } from '../db';
|
||||
|
||||
const hiscores = new Hono();
|
||||
|
||||
// Cache TTL in milliseconds (5 minutes)
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Skill names in order returned by the hiscores API
|
||||
const SKILL_NAMES = [
|
||||
'overall',
|
||||
'attack',
|
||||
'defence',
|
||||
'strength',
|
||||
'hitpoints',
|
||||
'ranged',
|
||||
'prayer',
|
||||
'magic',
|
||||
'cooking',
|
||||
'woodcutting',
|
||||
'fletching',
|
||||
'fishing',
|
||||
'firemaking',
|
||||
'crafting',
|
||||
'smithing',
|
||||
'mining',
|
||||
'herblore',
|
||||
'agility',
|
||||
'thieving',
|
||||
'slayer',
|
||||
'farming',
|
||||
'runecraft',
|
||||
'hunter',
|
||||
'construction',
|
||||
];
|
||||
|
||||
// Activity/minigame names (after skills in the CSV)
|
||||
const ACTIVITY_NAMES = [
|
||||
'league_points',
|
||||
'deadman_points',
|
||||
'bounty_hunter_hunter',
|
||||
'bounty_hunter_rogue',
|
||||
'bounty_hunter_hunter_legacy',
|
||||
'bounty_hunter_rogue_legacy',
|
||||
'clue_scrolls_all',
|
||||
'clue_scrolls_beginner',
|
||||
'clue_scrolls_easy',
|
||||
'clue_scrolls_medium',
|
||||
'clue_scrolls_hard',
|
||||
'clue_scrolls_elite',
|
||||
'clue_scrolls_master',
|
||||
'lms_rank',
|
||||
'pvp_arena_rank',
|
||||
'soul_wars_zeal',
|
||||
'rifts_closed',
|
||||
'colosseum_glory',
|
||||
];
|
||||
|
||||
/**
|
||||
* GET /api/hiscores/:rsn
|
||||
* Fetch hiscores from cache or Jagex seasonal (Leagues) hiscores
|
||||
*/
|
||||
hiscores.get('/:rsn', async (c) => {
|
||||
const rsn = c.req.param('rsn');
|
||||
|
||||
if (!rsn) {
|
||||
return c.json({ error: 'RSN is required' }, 400);
|
||||
}
|
||||
|
||||
const rsnLower = rsn.toLowerCase();
|
||||
|
||||
// Check cache first
|
||||
const cached = await prisma.hiscoresCache.findUnique({
|
||||
where: { rsn: rsnLower },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
if (cached && (now.getTime() - cached.fetchedAt.getTime()) < CACHE_TTL_MS) {
|
||||
// Return cached data
|
||||
return c.json({
|
||||
rsn: cached.displayRsn,
|
||||
skills: JSON.parse(cached.skills),
|
||||
clues: JSON.parse(cached.clues),
|
||||
activities: JSON.parse(cached.activities),
|
||||
leaguePoints: cached.leaguePoints,
|
||||
cached: true,
|
||||
cachedAt: cached.fetchedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch from Jagex API
|
||||
const url = `https://secure.runescape.com/m=hiscore_oldschool_seasonal/index_lite.ws?player=${encodeURIComponent(rsn)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'LeaguesTools/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return c.json({ status: 404, error: 'Player not found' }, 404);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// If fetch fails but we have stale cache, return it
|
||||
if (cached) {
|
||||
return c.json({
|
||||
rsn: cached.displayRsn,
|
||||
skills: JSON.parse(cached.skills),
|
||||
clues: JSON.parse(cached.clues),
|
||||
activities: JSON.parse(cached.activities),
|
||||
leaguePoints: cached.leaguePoints,
|
||||
cached: true,
|
||||
stale: true,
|
||||
cachedAt: cached.fetchedAt,
|
||||
});
|
||||
}
|
||||
console.error(`Hiscores API error: ${response.status}`);
|
||||
return c.json({ error: 'Failed to fetch hiscores' }, 502);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.trim().split('\n');
|
||||
|
||||
// Parse skills (first 24 lines)
|
||||
const skills: Record<string, { rank: number; level: number; xp: number }> = {};
|
||||
for (let i = 0; i < SKILL_NAMES.length && i < lines.length; i++) {
|
||||
const [rank, level, xp] = lines[i].split(',').map(Number);
|
||||
skills[SKILL_NAMES[i]] = { rank, level, xp };
|
||||
}
|
||||
|
||||
// Parse activities/minigames (remaining lines)
|
||||
const activities: Record<string, { rank: number; score: number }> = {};
|
||||
for (let i = SKILL_NAMES.length; i < lines.length; i++) {
|
||||
const activityIndex = i - SKILL_NAMES.length;
|
||||
if (activityIndex < ACTIVITY_NAMES.length) {
|
||||
const [rank, score] = lines[i].split(',').map(Number);
|
||||
activities[ACTIVITY_NAMES[activityIndex]] = { rank, score };
|
||||
}
|
||||
}
|
||||
|
||||
// Build clues object for frontend compatibility
|
||||
const clues = {
|
||||
all: { rank: activities.clue_scrolls_all?.rank ?? -1, score: activities.clue_scrolls_all?.score ?? -1 },
|
||||
beginner: { rank: activities.clue_scrolls_beginner?.rank ?? -1, score: activities.clue_scrolls_beginner?.score ?? -1 },
|
||||
easy: { rank: activities.clue_scrolls_easy?.rank ?? -1, score: activities.clue_scrolls_easy?.score ?? -1 },
|
||||
medium: { rank: activities.clue_scrolls_medium?.rank ?? -1, score: activities.clue_scrolls_medium?.score ?? -1 },
|
||||
hard: { rank: activities.clue_scrolls_hard?.rank ?? -1, score: activities.clue_scrolls_hard?.score ?? -1 },
|
||||
elite: { rank: activities.clue_scrolls_elite?.rank ?? -1, score: activities.clue_scrolls_elite?.score ?? -1 },
|
||||
master: { rank: activities.clue_scrolls_master?.rank ?? -1, score: activities.clue_scrolls_master?.score ?? -1 },
|
||||
};
|
||||
|
||||
const leaguePoints = activities.league_points?.score || 0;
|
||||
|
||||
// Cache the result
|
||||
await prisma.hiscoresCache.upsert({
|
||||
where: { rsn: rsnLower },
|
||||
update: {
|
||||
displayRsn: rsn,
|
||||
skills: JSON.stringify(skills),
|
||||
clues: JSON.stringify(clues),
|
||||
activities: JSON.stringify(activities),
|
||||
leaguePoints,
|
||||
fetchedAt: now,
|
||||
},
|
||||
create: {
|
||||
rsn: rsnLower,
|
||||
displayRsn: rsn,
|
||||
skills: JSON.stringify(skills),
|
||||
clues: JSON.stringify(clues),
|
||||
activities: JSON.stringify(activities),
|
||||
leaguePoints,
|
||||
fetchedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
rsn,
|
||||
skills,
|
||||
clues,
|
||||
activities,
|
||||
leaguePoints,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
// If fetch fails but we have stale cache, return it
|
||||
if (cached) {
|
||||
return c.json({
|
||||
rsn: cached.displayRsn,
|
||||
skills: JSON.parse(cached.skills),
|
||||
clues: JSON.parse(cached.clues),
|
||||
activities: JSON.parse(cached.activities),
|
||||
leaguePoints: cached.leaguePoints,
|
||||
cached: true,
|
||||
stale: true,
|
||||
cachedAt: cached.fetchedAt,
|
||||
});
|
||||
}
|
||||
console.error('Hiscores fetch error:', error);
|
||||
return c.json({ error: 'Failed to fetch hiscores' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default hiscores;
|
||||
123
server/src/utils/email.ts
Normal file
123
server/src/utils/email.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
// SMTP configuration from environment variables
|
||||
const smtpConfig = {
|
||||
host: process.env.SMTP_HOST || 'localhost',
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
};
|
||||
|
||||
// Default sender
|
||||
const defaultFrom = process.env.SMTP_FROM || 'noreply@leagues.tools';
|
||||
|
||||
// Create reusable transporter
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter(): nodemailer.Transporter {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport(smtpConfig);
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Check if email is configured
|
||||
export function isEmailConfigured(): boolean {
|
||||
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS);
|
||||
}
|
||||
|
||||
// Send email
|
||||
export async function sendEmail(options: {
|
||||
to: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
}): Promise<{ success: boolean; messageId?: string; error?: string }> {
|
||||
if (!isEmailConfigured()) {
|
||||
console.warn('Email not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASS environment variables.');
|
||||
return { success: false, error: 'Email service not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await getTransporter().sendMail({
|
||||
from: defaultFrom,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html,
|
||||
});
|
||||
|
||||
console.log('Email sent:', info.messageId);
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Failed to send email' };
|
||||
}
|
||||
}
|
||||
|
||||
// Send password reset email
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
resetToken: string,
|
||||
baseUrl: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const resetUrl = `${baseUrl}/reset-password?token=${resetToken}`;
|
||||
|
||||
const result = await sendEmail({
|
||||
to: email,
|
||||
subject: 'Password Reset Request - Leagues Tools',
|
||||
text: `
|
||||
You requested a password reset for your Leagues Tools account.
|
||||
|
||||
Click the link below to reset your password:
|
||||
${resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
`.trim(),
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #1a1a2e; color: #e94560; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #f5f5f5; padding: 30px; border-radius: 0 0 8px 8px; }
|
||||
.button { display: inline-block; background: #e94560; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Leagues Tools</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>You requested a password reset for your Leagues Tools account.</p>
|
||||
<p>Click the button below to reset your password:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="${resetUrl}" class="button">Reset Password</a>
|
||||
</p>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; font-size: 12px; color: #666;">${resetUrl}</p>
|
||||
<p><strong>This link will expire in 1 hour.</strong></p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Leagues Tools - OSRS League Tracker</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user