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

142
CLAUDE.md Normal file
View 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

View File

@@ -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 = {
},
],
};

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>
);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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])
}

View File

@@ -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';

View File

@@ -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;

View 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
View 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;
}