adding admin options for pw reset

This commit is contained in:
2026-01-30 22:15:31 -05:00
parent c2a6e1d41f
commit b7b32b4fe6
9 changed files with 678 additions and 1 deletions

View File

@@ -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();

View File

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

View File

@@ -0,0 +1,299 @@
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);
setGeneratedUrl({
url: data.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;

View 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;