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

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