stage #4
@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
username VARCHAR(100) UNIQUE NOT NULL,
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX idx_email (email),
|
INDEX idx_email (email),
|
||||||
INDEX idx_username (username)
|
INDEX idx_username (username)
|
||||||
@@ -76,3 +77,17 @@ CREATE TABLE IF NOT EXISTS tmdb_cache (
|
|||||||
UNIQUE KEY unique_query (query, media_type),
|
UNIQUE KEY unique_query (query, media_type),
|
||||||
INDEX idx_query (query)
|
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 friendRoutes from './routes/friends.js';
|
||||||
import tmdbRoutes from './routes/tmdb.js';
|
import tmdbRoutes from './routes/tmdb.js';
|
||||||
import leaderboardRoutes from './routes/leaderboard.js';
|
import leaderboardRoutes from './routes/leaderboard.js';
|
||||||
|
import adminRoutes from './routes/admin.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ app.use('/api/predictions', apiLimiter, predictionRoutes);
|
|||||||
app.use('/api/friends', apiLimiter, friendRoutes);
|
app.use('/api/friends', apiLimiter, friendRoutes);
|
||||||
app.use('/api/tmdb', tmdbLimiter, tmdbRoutes);
|
app.use('/api/tmdb', tmdbLimiter, tmdbRoutes);
|
||||||
app.use('/api/leaderboard', apiLimiter, leaderboardRoutes);
|
app.use('/api/leaderboard', apiLimiter, leaderboardRoutes);
|
||||||
|
app.use('/api/admin', apiLimiter, adminRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { query } from '../db/index.js';
|
||||||
|
import { asyncHandler } from './errorHandler.js';
|
||||||
|
|
||||||
export const authMiddleware = (req, res, next) => {
|
export const authMiddleware = (req, res, next) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
@@ -17,3 +19,35 @@ export const authMiddleware = (req, res, next) => {
|
|||||||
return res.status(401).json({ error: 'Invalid token' });
|
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 decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
const users = await query(
|
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]
|
[decoded.userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -106,4 +106,56 @@ router.get('/me', asyncHandler(async (req, res) => {
|
|||||||
res.json({ user: users[0] });
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -171,6 +171,29 @@ class API {
|
|||||||
async getProfile(userId) {
|
async getProfile(userId) {
|
||||||
return this.request(`/leaderboard/profile${userId ? `/${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();
|
export default new API();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import ChallengeDetail from './pages/ChallengeDetail';
|
|||||||
import Profile from './pages/Profile';
|
import Profile from './pages/Profile';
|
||||||
import Friends from './pages/Friends';
|
import Friends from './pages/Friends';
|
||||||
import Leaderboard from './pages/Leaderboard';
|
import Leaderboard from './pages/Leaderboard';
|
||||||
|
import Admin from './pages/Admin';
|
||||||
|
import PasswordReset from './pages/PasswordReset';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ function Header() {
|
|||||||
<li><Link to="/leaderboard" onClick={closeMobileMenu}>Leaderboard</Link></li>
|
<li><Link to="/leaderboard" onClick={closeMobileMenu}>Leaderboard</Link></li>
|
||||||
<li><Link to="/friends" onClick={closeMobileMenu}>Friends</Link></li>
|
<li><Link to="/friends" onClick={closeMobileMenu}>Friends</Link></li>
|
||||||
<li><Link to="/profile" onClick={closeMobileMenu}>Profile</Link></li>
|
<li><Link to="/profile" onClick={closeMobileMenu}>Profile</Link></li>
|
||||||
|
{!!user.is_admin && <li><Link to="/admin" onClick={closeMobileMenu}>Admin</Link></li>}
|
||||||
</ul>
|
</ul>
|
||||||
<button onClick={handleLogout} className="btn btn-secondary btn-sm logout-btn">
|
<button onClick={handleLogout} className="btn btn-secondary btn-sm logout-btn">
|
||||||
Logout
|
Logout
|
||||||
@@ -112,11 +115,13 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/reset-password/:token" element={<PasswordReset />} />
|
||||||
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
|
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
|
||||||
<Route path="/challenges/:id" element={<ProtectedRoute><ChallengeDetail /></ProtectedRoute>} />
|
<Route path="/challenges/:id" element={<ProtectedRoute><ChallengeDetail /></ProtectedRoute>} />
|
||||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||||
<Route path="/friends" element={<ProtectedRoute><Friends /></ProtectedRoute>} />
|
<Route path="/friends" element={<ProtectedRoute><Friends /></ProtectedRoute>} />
|
||||||
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
|
||||||
<Route path="/" element={<Navigate to="/challenges" />} />
|
<Route path="/" element={<Navigate to="/challenges" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</SocketProvider>
|
</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;
|
||||||
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;
|
||||||
Reference in New Issue
Block a user