From b7b32b4fe62319a02914e87f19b0ace7b799f697 Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Fri, 30 Jan 2026 22:15:31 -0500 Subject: [PATCH 1/3] adding admin options for pw reset --- backend/src/db/init.sql | 15 ++ backend/src/index.js | 2 + backend/src/middleware/auth.js | 34 +++ backend/src/routes/admin.js | 91 ++++++++ backend/src/routes/auth.js | 54 ++++- frontend/src/api.js | 23 +++ frontend/src/main.jsx | 5 + frontend/src/pages/Admin.jsx | 299 +++++++++++++++++++++++++++ frontend/src/pages/PasswordReset.jsx | 156 ++++++++++++++ 9 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/admin.js create mode 100644 frontend/src/pages/Admin.jsx create mode 100644 frontend/src/pages/PasswordReset.jsx 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() {
  • Leaderboard
  • Friends
  • Profile
  • + {user.is_admin &&
  • Admin
  • } + + + + {generatedUrl && ( +
    +

    ✓ Reset Link Generated

    +

    User: {generatedUrl.user.username} ({generatedUrl.user.email})

    +

    Expires: {new Date(generatedUrl.expiresAt).toLocaleString()}

    +
    + + +
    + +
    + )} + + {error && ( +
    + {error} +
    + )} + + {loading ? ( +

    Loading...

    + ) : activeTab === 'users' ? ( +
    +

    Users ({users.length})

    +
    + + + + + + + + + + + + + {users.map(u => ( + + + + + + + + + ))} + +
    IDUsernameEmailAdminCreatedActions
    {u.id}{u.username}{u.email}{u.is_admin ? '✓' : ''} + {new Date(u.created_at).toLocaleDateString()} + + +
    +
    +
    + ) : ( +
    +

    Password Reset Tokens

    +
    + + + + + + + + + + + + {resetTokens.map(token => { + const now = new Date(); + const expired = new Date(token.expires_at) < now; + const used = !!token.used_at; + let status = 'Active'; + let statusColor = '#28a745'; + + if (used) { + status = 'Used'; + statusColor = '#6c757d'; + } else if (expired) { + status = 'Expired'; + statusColor = '#dc3545'; + } + + return ( + + + + + + + + ); + })} + +
    UserCreated ByCreated AtExpires AtStatus
    + {token.username} +
    + {token.email} +
    {token.created_by_username} + {new Date(token.created_at).toLocaleString()} + + {new Date(token.expires_at).toLocaleString()} + + + {status} + +
    +
    +
    + )} + + + ); +} + +export default Admin; diff --git a/frontend/src/pages/PasswordReset.jsx b/frontend/src/pages/PasswordReset.jsx new file mode 100644 index 0000000..b5ff8da --- /dev/null +++ b/frontend/src/pages/PasswordReset.jsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import api from '../api'; +import '../App.css'; + +function PasswordReset() { + const { token } = useParams(); + const navigate = useNavigate(); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(null); + + if (newPassword.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + if (newPassword !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setLoading(true); + + try { + await api.resetPassword(token, newPassword); + setSuccess(true); + setTimeout(() => { + navigate('/login'); + }, 3000); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
    +
    +

    ✓ Password Reset Successful

    +

    Your password has been updated successfully.

    +

    Redirecting to login page...

    +
    +
    + ); + } + + return ( +
    +
    +

    Reset Your Password

    +

    + Enter your new password below. +

    + + {error && ( +
    + {error} +
    + )} + +
    +
    + + setNewPassword(e.target.value)} + required + minLength={6} + style={{ + width: '100%', + padding: '0.75rem', + background: '#222', + color: '#fff', + border: '1px solid #444', + borderRadius: '4px', + fontSize: '1rem' + }} + placeholder="Enter new password" + /> +
    + +
    + + setConfirmPassword(e.target.value)} + required + minLength={6} + style={{ + width: '100%', + padding: '0.75rem', + background: '#222', + color: '#fff', + border: '1px solid #444', + borderRadius: '4px', + fontSize: '1rem' + }} + placeholder="Confirm new password" + /> +
    + + +
    + +
    + + Back to Login + +
    +
    +
    + ); +} + +export default PasswordReset; -- 2.49.1 From 352a23f008f32d38e8341732383d42c1299ec2d8 Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Fri, 30 Jan 2026 22:27:35 -0500 Subject: [PATCH 2/3] cleanup visible bool false --- frontend/src/main.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index cd78f29..3a8d715 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -70,7 +70,7 @@ function Header() {
  • Leaderboard
  • Friends
  • Profile
  • - {user.is_admin &&
  • Admin
  • } + {!!user.is_admin &&
  • Admin
  • }