Compare commits
12 Commits
1070f9b3d2
...
stage
| Author | SHA1 | Date | |
|---|---|---|---|
| a0931df2f4 | |||
| 33026a6ecd | |||
| 352a23f008 | |||
| b7b32b4fe6 | |||
| a725e7a0d1 | |||
| c2a6e1d41f | |||
| be91e1a078 | |||
| a5b0d3352b | |||
| bb3a2e0b65 | |||
| 854b7b76a3 | |||
| f47ea8efaa | |||
| 75d6eb8bbc |
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
88
backend/src/routes/admin.js
Normal file
88
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,88 @@
|
||||
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]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
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;
|
||||
@@ -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;
|
||||
|
||||
@@ -213,6 +213,43 @@ router.post('/:id/respond', authMiddleware, asyncHandler(async (req, res) => {
|
||||
res.json({ status });
|
||||
}));
|
||||
|
||||
// Leave a challenge (participants only, not creator)
|
||||
router.post('/:id/leave', authMiddleware, asyncHandler(async (req, res) => {
|
||||
const challengeId = req.params.id;
|
||||
|
||||
// Get challenge to verify it exists and user is not creator
|
||||
const challenges = await query(
|
||||
'SELECT * FROM challenges WHERE id = ?',
|
||||
[challengeId]
|
||||
);
|
||||
|
||||
if (challenges.length === 0) {
|
||||
throw new AppError('Challenge not found', 404);
|
||||
}
|
||||
|
||||
const challenge = challenges[0];
|
||||
|
||||
if (challenge.created_by === req.user.userId) {
|
||||
throw new AppError('Challenge creator cannot leave. Delete the challenge instead.', 403);
|
||||
}
|
||||
|
||||
// Check if user is a participant
|
||||
const participation = await query(
|
||||
'SELECT * FROM challenge_participants WHERE challenge_id = ? AND user_id = ?',
|
||||
[challengeId, req.user.userId]
|
||||
);
|
||||
|
||||
if (participation.length === 0) {
|
||||
throw new AppError('You are not a participant of this challenge', 404);
|
||||
}
|
||||
|
||||
// Delete user's participation and predictions
|
||||
await query('DELETE FROM predictions WHERE challenge_id = ? AND user_id = ?', [challengeId, req.user.userId]);
|
||||
await query('DELETE FROM challenge_participants WHERE challenge_id = ? AND user_id = ?', [challengeId, req.user.userId]);
|
||||
|
||||
res.json({ success: true, message: 'Left challenge successfully' });
|
||||
}));
|
||||
|
||||
// Delete a challenge (only creator can delete)
|
||||
router.delete('/:id', authMiddleware, asyncHandler(async (req, res) => {
|
||||
const challengeId = req.params.id;
|
||||
|
||||
@@ -148,6 +148,12 @@ class API {
|
||||
});
|
||||
}
|
||||
|
||||
async leaveChallenge(challengeId) {
|
||||
return this.request(`/challenges/${challengeId}/leave`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
// TMDB
|
||||
async searchShows(query) {
|
||||
return this.request(`/tmdb/search?q=${encodeURIComponent(query)}`);
|
||||
@@ -165,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();
|
||||
|
||||
@@ -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() {
|
||||
<li><Link to="/leaderboard" onClick={closeMobileMenu}>Leaderboard</Link></li>
|
||||
<li><Link to="/friends" onClick={closeMobileMenu}>Friends</Link></li>
|
||||
<li><Link to="/profile" onClick={closeMobileMenu}>Profile</Link></li>
|
||||
{!!user.is_admin && <li><Link to="/admin" onClick={closeMobileMenu}>Admin</Link></li>}
|
||||
</ul>
|
||||
<button onClick={handleLogout} className="btn btn-secondary btn-sm logout-btn">
|
||||
Logout
|
||||
@@ -112,11 +115,13 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/reset-password/:token" element={<PasswordReset />} />
|
||||
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
|
||||
<Route path="/challenges/:id" element={<ProtectedRoute><ChallengeDetail /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
<Route path="/friends" element={<ProtectedRoute><Friends /></ProtectedRoute>} />
|
||||
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
|
||||
<Route path="/" element={<Navigate to="/challenges" />} />
|
||||
</Routes>
|
||||
</SocketProvider>
|
||||
|
||||
301
frontend/src/pages/Admin.jsx
Normal file
301
frontend/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import api from '../api';
|
||||
import '../App.css';
|
||||
|
||||
function Admin() {
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [resetTokens, setResetTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [generatedUrl, setGeneratedUrl] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) {
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [user, activeTab]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (activeTab === 'users') {
|
||||
const data = await api.getUsers();
|
||||
setUsers(data.users);
|
||||
} else {
|
||||
const data = await api.getResetTokens();
|
||||
setResetTokens(data.tokens);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateResetToken = async (userId) => {
|
||||
try {
|
||||
const data = await api.generateResetToken(userId);
|
||||
// Build URL using current domain (works in dev and prod)
|
||||
const resetUrl = `${window.location.origin}/reset-password/${data.token}`;
|
||||
setGeneratedUrl({
|
||||
url: resetUrl,
|
||||
user: data.user,
|
||||
expiresAt: data.expiresAt
|
||||
});
|
||||
// Refresh the list if on tokens tab
|
||||
if (activeTab === 'tokens') {
|
||||
loadData();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
alert('Copied to clipboard!');
|
||||
};
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card">
|
||||
<h1>Access Denied</h1>
|
||||
<p>Admin privileges required.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card">
|
||||
<h1>Admin Panel</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', borderBottom: '2px solid #333' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: activeTab === 'users' ? '#007bff' : 'transparent',
|
||||
color: activeTab === 'users' ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'users' ? '3px solid #007bff' : 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: activeTab === 'users' ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('tokens')}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: activeTab === 'tokens' ? '#007bff' : 'transparent',
|
||||
color: activeTab === 'tokens' ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'tokens' ? '3px solid #007bff' : 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: activeTab === 'tokens' ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
Reset Tokens
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generatedUrl && (
|
||||
<div style={{
|
||||
padding: '1.5rem',
|
||||
background: '#1a3a1a',
|
||||
border: '2px solid #28a745',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
<h3 style={{ color: '#28a745', marginTop: 0 }}>✓ Reset Link Generated</h3>
|
||||
<p><strong>User:</strong> {generatedUrl.user.username} ({generatedUrl.user.email})</p>
|
||||
<p><strong>Expires:</strong> {new Date(generatedUrl.expiresAt).toLocaleString()}</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginTop: '1rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={generatedUrl.url}
|
||||
readOnly
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
background: '#222',
|
||||
color: '#fff',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyToClipboard(generatedUrl.url)}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setGeneratedUrl(null)}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: '#3a1a1a',
|
||||
border: '2px solid #dc3545',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1rem',
|
||||
color: '#dc3545'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : activeTab === 'users' ? (
|
||||
<div>
|
||||
<h2>Users ({users.length})</h2>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #444' }}>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>ID</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Username</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Email</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Admin</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Created</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} style={{ borderBottom: '1px solid #333' }}>
|
||||
<td style={{ padding: '1rem' }}>{u.id}</td>
|
||||
<td style={{ padding: '1rem' }}>{u.username}</td>
|
||||
<td style={{ padding: '1rem' }}>{u.email}</td>
|
||||
<td style={{ padding: '1rem' }}>{u.is_admin ? '✓' : ''}</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
<button
|
||||
onClick={() => handleGenerateResetToken(u.id)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Generate Reset Link
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2>Password Reset Tokens</h2>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #444' }}>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>User</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Created By</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Created At</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Expires At</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={token.id} style={{ borderBottom: '1px solid #333' }}>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
{token.username}
|
||||
<br />
|
||||
<small style={{ color: '#999' }}>{token.email}</small>
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>{token.created_by_username}</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
{new Date(token.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
{new Date(token.expires_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
<span style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: statusColor,
|
||||
borderRadius: '12px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Admin;
|
||||
@@ -25,6 +25,7 @@ export default function ChallengeDetail() {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [inviting, setInviting] = useState(null);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
|
||||
useClickOutside(searchRef, () => setSearchResults([]));
|
||||
@@ -235,6 +236,22 @@ export default function ChallengeDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!confirm('Are you sure you want to leave this challenge? Your predictions will be removed.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLeaving(true);
|
||||
try {
|
||||
await api.leaveChallenge(id);
|
||||
toast.success('Left challenge successfully');
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
toast.error('Failed to leave challenge: ' + err.message);
|
||||
setLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading challenge...</div>;
|
||||
}
|
||||
@@ -271,7 +288,7 @@ export default function ChallengeDetail() {
|
||||
>
|
||||
{showInvite ? 'Cancel' : 'Invite Friends'}
|
||||
</button>
|
||||
{challenge.challenge.created_by === user.id && (
|
||||
{challenge.challenge.created_by === user.id ? (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
@@ -279,6 +296,14 @@ export default function ChallengeDetail() {
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Challenge'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleLeave}
|
||||
disabled={leaving}
|
||||
>
|
||||
{leaving ? 'Leaving...' : 'Leave Challenge'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function Friends() {
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleSendRequest(user.id)}
|
||||
disabled={sending === user.id}
|
||||
style={{ flexShrink: 0 }}
|
||||
style={{ flexShrink: 0, width: 'auto' }}
|
||||
>
|
||||
{sending === user.id ? 'Sending...' : 'Add Friend'}
|
||||
</button>
|
||||
@@ -263,8 +263,8 @@ export default function Friends() {
|
||||
>
|
||||
{removing === friend.id ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
<div style={{ color: 'var(--primary)', whiteSpace: 'nowrap' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)', whiteSpace: 'nowrap' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
))}
|
||||
{challengeFriends.length > 0 && (
|
||||
@@ -296,8 +296,8 @@ export default function Friends() {
|
||||
>
|
||||
{sending === friend.id ? 'Sending...' : friend.friendship_status === 'pending' ? 'Pending' : 'Add Friend'}
|
||||
</button>
|
||||
<div style={{ color: 'var(--primary)', whiteSpace: 'nowrap' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)', whiteSpace: 'nowrap' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from '../AuthContext';
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -42,13 +43,34 @@ export default function Login() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0.5rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{showPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1rem' }} disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
|
||||
156
frontend/src/pages/PasswordReset.jsx
Normal file
156
frontend/src/pages/PasswordReset.jsx
Normal file
@@ -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 (
|
||||
<div className="container">
|
||||
<div className="card" style={{ maxWidth: '500px', margin: '2rem auto' }}>
|
||||
<h1 style={{ color: '#28a745' }}>✓ Password Reset Successful</h1>
|
||||
<p>Your password has been updated successfully.</p>
|
||||
<p>Redirecting to login page...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card" style={{ maxWidth: '500px', margin: '2rem auto' }}>
|
||||
<h1>Reset Your Password</h1>
|
||||
<p style={{ color: '#999', marginBottom: '2rem' }}>
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: '#3a1a1a',
|
||||
border: '2px solid #dc3545',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1rem',
|
||||
color: '#dc3545'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
background: loading ? '#666' : '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Resetting Password...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
||||
<a
|
||||
href="/login"
|
||||
style={{ color: '#007bff', textDecoration: 'none' }}
|
||||
>
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordReset;
|
||||
@@ -7,6 +7,7 @@ export default function Register() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -53,14 +54,35 @@ export default function Register() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0.5rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{showPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1rem' }} disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Register'}
|
||||
|
||||
Reference in New Issue
Block a user