diff --git a/backend/src/db/init.sql b/backend/src/db/init.sql index 42282f4..282f5f3 100644 --- a/backend/src/db/init.sql +++ b/backend/src/db/init.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users ( email VARCHAR(255) UNIQUE NOT NULL, username VARCHAR(100) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, + is_admin BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_email (email), INDEX idx_username (username) @@ -76,3 +77,17 @@ CREATE TABLE IF NOT EXISTS tmdb_cache ( UNIQUE KEY unique_query (query, media_type), INDEX idx_query (query) ); + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token VARCHAR(255) UNIQUE NOT NULL, + created_by INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token), + INDEX idx_user_id (user_id) +); diff --git a/backend/src/index.js b/backend/src/index.js index 911fa0f..3c76f02 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -16,6 +16,7 @@ import predictionRoutes from './routes/predictions.js'; import friendRoutes from './routes/friends.js'; import tmdbRoutes from './routes/tmdb.js'; import leaderboardRoutes from './routes/leaderboard.js'; +import adminRoutes from './routes/admin.js'; dotenv.config(); @@ -67,6 +68,7 @@ app.use('/api/predictions', apiLimiter, predictionRoutes); app.use('/api/friends', apiLimiter, friendRoutes); app.use('/api/tmdb', tmdbLimiter, tmdbRoutes); app.use('/api/leaderboard', apiLimiter, leaderboardRoutes); +app.use('/api/admin', apiLimiter, adminRoutes); // Health check app.get('/api/health', (req, res) => { diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 7760577..1a9fd63 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,4 +1,6 @@ import jwt from 'jsonwebtoken'; +import { query } from '../db/index.js'; +import { asyncHandler } from './errorHandler.js'; export const authMiddleware = (req, res, next) => { const authHeader = req.headers.authorization; @@ -17,3 +19,35 @@ export const authMiddleware = (req, res, next) => { return res.status(401).json({ error: 'Invalid token' }); } }; + +// Enhanced version that fetches user data including admin status +export const verifyToken = asyncHandler(async (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No token provided' }); + } + + const token = authHeader.substring(7); + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Fetch full user data including admin status + const users = await query( + 'SELECT id, email, username, is_admin FROM users WHERE id = ?', + [decoded.userId] + ); + + if (users.length === 0) { + return res.status(401).json({ error: 'User not found' }); + } + + req.user = { + userId: users[0].id, + email: users[0].email, + username: users[0].username, + is_admin: users[0].is_admin + }; + + next(); +}); + diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..5a80b93 --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,91 @@ +import express from 'express'; +import crypto from 'crypto'; +import { query } from '../db/index.js'; +import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { verifyToken } from '../middleware/auth.js'; + +const router = express.Router(); + +// Middleware to check if user is admin +const requireAdmin = asyncHandler(async (req, res, next) => { + if (!req.user || !req.user.is_admin) { + throw new AppError('Admin access required', 403); + } + next(); +}); + +// Get all users (admin only) +router.get('/users', verifyToken, requireAdmin, asyncHandler(async (req, res) => { + const users = await query( + 'SELECT id, email, username, is_admin, created_at FROM users ORDER BY created_at DESC' + ); + res.json({ users }); +})); + +// Generate password reset token (admin only) +router.post('/generate-reset-token', verifyToken, requireAdmin, asyncHandler(async (req, res) => { + const { userId } = req.body; + + if (!userId) { + throw new AppError('User ID required', 400); + } + + // Verify user exists + const users = await query('SELECT id, username, email FROM users WHERE id = ?', [userId]); + if (users.length === 0) { + throw new AppError('User not found', 404); + } + + const user = users[0]; + + // Generate secure token + const token = crypto.randomBytes(32).toString('hex'); + + // Token expires in 1 hour + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + // Store token + await query( + 'INSERT INTO password_reset_tokens (user_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)', + [userId, token, req.user.userId, expiresAt] + ); + + // Generate reset URL + const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/reset-password/${token}`; + + res.json({ + success: true, + resetUrl, + user: { + id: user.id, + username: user.username, + email: user.email + }, + expiresAt + }); +})); + +// Get reset token history (admin only) +router.get('/reset-tokens', verifyToken, requireAdmin, asyncHandler(async (req, res) => { + const tokens = await query(` + SELECT + prt.id, + prt.token, + prt.created_at, + prt.expires_at, + prt.used_at, + u.id as user_id, + u.username, + u.email, + admin.username as created_by_username + FROM password_reset_tokens prt + JOIN users u ON prt.user_id = u.id + JOIN users admin ON prt.created_by = admin.id + ORDER BY prt.created_at DESC + LIMIT 100 + `); + + res.json({ tokens }); +})); + +export default router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 59be7fc..5d2f733 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -95,7 +95,7 @@ router.get('/me', asyncHandler(async (req, res) => { const decoded = jwt.verify(token, process.env.JWT_SECRET); const users = await query( - 'SELECT id, email, username, created_at FROM users WHERE id = ?', + 'SELECT id, email, username, is_admin, created_at FROM users WHERE id = ?', [decoded.userId] ); @@ -106,4 +106,56 @@ router.get('/me', asyncHandler(async (req, res) => { res.json({ user: users[0] }); })); +// Reset password with token (public route) +router.post('/reset-password', asyncHandler(async (req, res) => { + const { token, newPassword } = req.body; + + if (!token || !newPassword) { + throw new AppError('Token and new password required', 400); + } + + if (newPassword.length < 6) { + throw new AppError('Password must be at least 6 characters', 400); + } + + // Find valid token + const tokens = await query( + `SELECT prt.*, u.email, u.username + FROM password_reset_tokens prt + JOIN users u ON prt.user_id = u.id + WHERE prt.token = ? AND prt.used_at IS NULL AND prt.expires_at > NOW()`, + [token] + ); + + if (tokens.length === 0) { + throw new AppError('Invalid or expired reset token', 400); + } + + const resetToken = tokens[0]; + + // Hash new password + const password_hash = await bcrypt.hash(newPassword, 10); + + // Update password + await query( + 'UPDATE users SET password_hash = ? WHERE id = ?', + [password_hash, resetToken.user_id] + ); + + // Mark token as used + await query( + 'UPDATE password_reset_tokens SET used_at = NOW() WHERE id = ?', + [resetToken.id] + ); + + res.json({ + success: true, + message: 'Password reset successfully', + user: { + email: resetToken.email, + username: resetToken.username + } + }); +})); + export default router; diff --git a/frontend/src/api.js b/frontend/src/api.js index 40c94ac..5ac349b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -171,6 +171,29 @@ class API { async getProfile(userId) { return this.request(`/leaderboard/profile${userId ? `/${userId}` : ''}`); } + + // Admin + async getUsers() { + return this.request('/admin/users'); + } + + async generateResetToken(userId) { + return this.request('/admin/generate-reset-token', { + method: 'POST', + body: JSON.stringify({ userId }) + }); + } + + async getResetTokens() { + return this.request('/admin/reset-tokens'); + } + + async resetPassword(token, newPassword) { + return this.request('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ token, newPassword }) + }); + } } export default new API(); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index bec3663..cd78f29 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -11,6 +11,8 @@ import ChallengeDetail from './pages/ChallengeDetail'; import Profile from './pages/Profile'; import Friends from './pages/Friends'; import Leaderboard from './pages/Leaderboard'; +import Admin from './pages/Admin'; +import PasswordReset from './pages/PasswordReset'; import ErrorBoundary from './components/ErrorBoundary'; import './App.css'; @@ -68,6 +70,7 @@ function Header() {