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:
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