feat: add admin user system with role-based access control
Implement comprehensive admin user system for Kubernetes deployment: Backend: - Add isAdmin field to User model for role-based permissions - Create adminAuth middleware to protect admin-only routes - Protect 11 routes across rewards, cache, streets, and analytics endpoints - Update setup-couchdb.js to seed default admin user at deployment Kubernetes: - Add ADMIN_EMAIL and ADMIN_PASSWORD to secrets.yaml - Add ADMIN_EMAIL to configmap.yaml for non-sensitive config - Create couchdb-init-job.yaml for automated database initialization - Update secrets.yaml.example with admin user documentation Frontend: - Create AdminRoute component for admin-only page protection - Create comprehensive AdminDashboard with 5 tabs: * Overview: Platform statistics and quick actions * Users: List, search, manage admin status, delete users * Streets: Create, edit, delete streets * Rewards: Create, edit, toggle, delete rewards * Content: Moderate posts and events - Add Admin navigation link in Navbar (visible only to admins) - Integrate admin routes in App.js Default admin user: - Email: will@wills-portal.com - Created automatically by K8s init job at deployment Routes protected: - POST/PUT/DELETE /api/rewards (catalog management) - POST /api/streets (street creation) - DELETE /api/cache (cache operations) - GET /api/analytics/* (platform statistics) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,8 @@ import Premium from "./components/Premium";
|
||||
import Analytics from "./components/Analytics";
|
||||
import Navbar from "./components/Navbar";
|
||||
import PrivateRoute from "./components/PrivateRoute";
|
||||
import AdminRoute from "./components/AdminRoute";
|
||||
import AdminDashboard from "./components/AdminDashboard";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -40,8 +42,9 @@ function App() {
|
||||
<Route path="/rewards" element={<PrivateRoute><Rewards /></PrivateRoute>} />
|
||||
<Route path="/leaderboard" element={<PrivateRoute><Leaderboard /></PrivateRoute>} />
|
||||
<Route path="/premium" element={<PrivateRoute><Premium /></PrivateRoute>} />
|
||||
<Route path="/analytics" element={<PrivateRoute><Analytics /></PrivateRoute>} />
|
||||
<Route path="/" element={<Navigate to="/map" replace />} />
|
||||
<Route path="/analytics" element={<PrivateRoute><Analytics /></PrivateRoute>} />
|
||||
<Route path="/admin/*" element={<AdminRoute><AdminDashboard /></AdminRoute>} />
|
||||
<Route path="/" element={<Navigate to="/map" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<ToastContainer
|
||||
|
||||
909
frontend/src/components/AdminDashboard.js
Normal file
909
frontend/src/components/AdminDashboard.js
Normal file
@@ -0,0 +1,909 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Overview state
|
||||
const [statistics, setStatistics] = useState(null);
|
||||
|
||||
// Users state
|
||||
const [users, setUsers] = useState([]);
|
||||
const [searchUsers, setSearchUsers] = useState("");
|
||||
|
||||
// Streets state
|
||||
const [streets, setStreets] = useState([]);
|
||||
const [newStreet, setNewStreet] = useState({ name: "", location: "", description: "" });
|
||||
const [editingStreet, setEditingStreet] = useState(null);
|
||||
|
||||
// Rewards state
|
||||
const [rewards, setRewards] = useState([]);
|
||||
const [newReward, setNewReward] = useState({ name: "", pointsCost: "", active: true });
|
||||
const [editingReward, setEditingReward] = useState(null);
|
||||
|
||||
// Content state
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [events, setEvents] = useState([]);
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const axiosConfig = { headers: { "x-auth-token": token } };
|
||||
|
||||
useEffect(() => {
|
||||
switch (activeTab) {
|
||||
case "overview":
|
||||
fetchStatistics();
|
||||
break;
|
||||
case "users":
|
||||
fetchUsers();
|
||||
break;
|
||||
case "streets":
|
||||
fetchStreets();
|
||||
break;
|
||||
case "rewards":
|
||||
fetchRewards();
|
||||
break;
|
||||
case "content":
|
||||
fetchContent();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
// Overview Tab Functions
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/analytics", axiosConfig);
|
||||
setStatistics(res.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch statistics:", err);
|
||||
setError("Failed to load statistics");
|
||||
toast.error("Failed to load statistics");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Users Tab Functions
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/users", axiosConfig);
|
||||
setUsers(res.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch users:", err);
|
||||
setError("Failed to load users");
|
||||
toast.error("Failed to load users");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAdminStatus = async (userId, currentStatus) => {
|
||||
if (!window.confirm(`Are you sure you want to ${currentStatus ? "remove" : "grant"} admin status?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.put(
|
||||
`/api/users/${userId}/admin`,
|
||||
{ isAdmin: !currentStatus },
|
||||
axiosConfig
|
||||
);
|
||||
toast.success("Admin status updated");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
console.error("Failed to update admin status:", err);
|
||||
toast.error("Failed to update admin status");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (userId) => {
|
||||
if (!window.confirm("Are you sure you want to delete this user? This action cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/users/${userId}`, axiosConfig);
|
||||
toast.success("User deleted");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete user:", err);
|
||||
toast.error("Failed to delete user");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.name?.toLowerCase().includes(searchUsers.toLowerCase()) ||
|
||||
user.email?.toLowerCase().includes(searchUsers.toLowerCase())
|
||||
);
|
||||
|
||||
// Streets Tab Functions
|
||||
const fetchStreets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/streets", axiosConfig);
|
||||
setStreets(res.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch streets:", err);
|
||||
setError("Failed to load streets");
|
||||
toast.error("Failed to load streets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createStreet = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newStreet.name || !newStreet.location) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.post("/api/streets", newStreet, axiosConfig);
|
||||
toast.success("Street created successfully");
|
||||
setNewStreet({ name: "", location: "", description: "" });
|
||||
fetchStreets();
|
||||
} catch (err) {
|
||||
console.error("Failed to create street:", err);
|
||||
toast.error("Failed to create street");
|
||||
}
|
||||
};
|
||||
|
||||
const updateStreet = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!editingStreet.name || !editingStreet.location) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.put(`/api/streets/${editingStreet._id}`, editingStreet, axiosConfig);
|
||||
toast.success("Street updated successfully");
|
||||
setEditingStreet(null);
|
||||
fetchStreets();
|
||||
} catch (err) {
|
||||
console.error("Failed to update street:", err);
|
||||
toast.error("Failed to update street");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteStreet = async (streetId) => {
|
||||
if (!window.confirm("Are you sure you want to delete this street?")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/streets/${streetId}`, axiosConfig);
|
||||
toast.success("Street deleted");
|
||||
fetchStreets();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete street:", err);
|
||||
toast.error("Failed to delete street");
|
||||
}
|
||||
};
|
||||
|
||||
// Rewards Tab Functions
|
||||
const fetchRewards = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/rewards", axiosConfig);
|
||||
setRewards(res.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch rewards:", err);
|
||||
setError("Failed to load rewards");
|
||||
toast.error("Failed to load rewards");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createReward = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newReward.name || !newReward.pointsCost) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.post("/api/rewards", {
|
||||
...newReward,
|
||||
pointsCost: parseInt(newReward.pointsCost),
|
||||
}, axiosConfig);
|
||||
toast.success("Reward created successfully");
|
||||
setNewReward({ name: "", pointsCost: "", active: true });
|
||||
fetchRewards();
|
||||
} catch (err) {
|
||||
console.error("Failed to create reward:", err);
|
||||
toast.error("Failed to create reward");
|
||||
}
|
||||
};
|
||||
|
||||
const updateReward = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!editingReward.name || !editingReward.pointsCost) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.put(`/api/rewards/${editingReward._id}`, {
|
||||
...editingReward,
|
||||
pointsCost: parseInt(editingReward.pointsCost),
|
||||
}, axiosConfig);
|
||||
toast.success("Reward updated successfully");
|
||||
setEditingReward(null);
|
||||
fetchRewards();
|
||||
} catch (err) {
|
||||
console.error("Failed to update reward:", err);
|
||||
toast.error("Failed to update reward");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRewardStatus = async (rewardId, currentStatus) => {
|
||||
try {
|
||||
await axios.patch(
|
||||
`/api/rewards/${rewardId}`,
|
||||
{ active: !currentStatus },
|
||||
axiosConfig
|
||||
);
|
||||
toast.success("Reward status updated");
|
||||
fetchRewards();
|
||||
} catch (err) {
|
||||
console.error("Failed to update reward status:", err);
|
||||
toast.error("Failed to update reward status");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReward = async (rewardId) => {
|
||||
if (!window.confirm("Are you sure you want to delete this reward?")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/rewards/${rewardId}`, axiosConfig);
|
||||
toast.success("Reward deleted");
|
||||
fetchRewards();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete reward:", err);
|
||||
toast.error("Failed to delete reward");
|
||||
}
|
||||
};
|
||||
|
||||
// Content Tab Functions
|
||||
const fetchContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [postsRes, eventsRes] = await Promise.all([
|
||||
axios.get("/api/posts?limit=20", axiosConfig),
|
||||
axios.get("/api/events?limit=20", axiosConfig),
|
||||
]);
|
||||
setPosts(postsRes.data);
|
||||
setEvents(eventsRes.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch content:", err);
|
||||
setError("Failed to load content");
|
||||
toast.error("Failed to load content");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePost = async (postId) => {
|
||||
if (!window.confirm("Are you sure you want to delete this post?")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/posts/${postId}`, axiosConfig);
|
||||
toast.success("Post deleted");
|
||||
fetchContent();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete post:", err);
|
||||
toast.error("Failed to delete post");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEvent = async (eventId) => {
|
||||
if (!window.confirm("Are you sure you want to delete this event?")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/events/${eventId}`, axiosConfig);
|
||||
toast.success("Event deleted");
|
||||
fetchContent();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete event:", err);
|
||||
toast.error("Failed to delete event");
|
||||
}
|
||||
};
|
||||
|
||||
// Render Overview Tab
|
||||
const renderOverviewTab = () => (
|
||||
<div>
|
||||
<h4 className="mb-4">Platform Statistics</h4>
|
||||
{loading ? (
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : statistics ? (
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-4 mb-3">
|
||||
<div className="card text-white bg-primary">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Total Users</h5>
|
||||
<p className="card-text fs-3">{statistics.totalUsers || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4 mb-3">
|
||||
<div className="card text-white bg-success">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Adopted Streets</h5>
|
||||
<p className="card-text fs-3">{statistics.totalStreets || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4 mb-3">
|
||||
<div className="card text-white bg-info">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Completed Tasks</h5>
|
||||
<p className="card-text fs-3">{statistics.totalTasks || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4 mb-3">
|
||||
<div className="card text-white bg-warning">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Active Events</h5>
|
||||
<p className="card-text fs-3">{statistics.totalEvents || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4 mb-3">
|
||||
<div className="card text-white bg-danger">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Total Posts</h5>
|
||||
<p className="card-text fs-3">{statistics.totalPosts || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h4 className="mt-4 mb-4">Quick Actions</h4>
|
||||
<div className="row">
|
||||
<div className="col-md-3 mb-2">
|
||||
<button
|
||||
className="btn btn-primary w-100"
|
||||
onClick={() => setActiveTab("streets")}
|
||||
>
|
||||
Create Street
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-md-3 mb-2">
|
||||
<button
|
||||
className="btn btn-success w-100"
|
||||
onClick={() => setActiveTab("rewards")}
|
||||
>
|
||||
Create Reward
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-md-3 mb-2">
|
||||
<button
|
||||
className="btn btn-info w-100"
|
||||
onClick={() => setActiveTab("users")}
|
||||
>
|
||||
Manage Users
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-md-3 mb-2">
|
||||
<button
|
||||
className="btn btn-warning w-100"
|
||||
onClick={() => setActiveTab("content")}
|
||||
>
|
||||
Moderate Content
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Users Tab
|
||||
const renderUsersTab = () => (
|
||||
<div>
|
||||
<h4 className="mb-4">User Management</h4>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchUsers}
|
||||
onChange={(e) => setSearchUsers(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Admin Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.length > 0 ? (
|
||||
filteredUsers.map((user) => (
|
||||
<tr key={user._id}>
|
||||
<td>{user.name}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
<span className={`badge ${user.isAdmin ? "bg-success" : "bg-secondary"}`}>
|
||||
{user.isAdmin ? "Admin" : "User"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-warning me-2"
|
||||
onClick={() => toggleAdminStatus(user._id, user.isAdmin)}
|
||||
>
|
||||
{user.isAdmin ? "Revoke Admin" : "Grant Admin"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => deleteUser(user._id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="4" className="text-center text-muted">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Streets Tab
|
||||
const renderStreetsTab = () => (
|
||||
<div>
|
||||
<h4 className="mb-4">Street Management</h4>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header bg-primary text-white">
|
||||
<h5 className="mb-0">{editingStreet ? "Edit Street" : "Create New Street"}</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={editingStreet ? updateStreet : createStreet}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Street Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={editingStreet?.name || newStreet.name}
|
||||
onChange={(e) =>
|
||||
editingStreet
|
||||
? setEditingStreet({ ...editingStreet, name: e.target.value })
|
||||
: setNewStreet({ ...newStreet, name: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Location *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={editingStreet?.location || newStreet.location}
|
||||
onChange={(e) =>
|
||||
editingStreet
|
||||
? setEditingStreet({ ...editingStreet, location: e.target.value })
|
||||
: setNewStreet({ ...newStreet, location: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Description</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows="3"
|
||||
value={editingStreet?.description || newStreet.description}
|
||||
onChange={(e) =>
|
||||
editingStreet
|
||||
? setEditingStreet({ ...editingStreet, description: e.target.value })
|
||||
: setNewStreet({ ...newStreet, description: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary me-2">
|
||||
{editingStreet ? "Update Street" : "Create Street"}
|
||||
</button>
|
||||
{editingStreet && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setEditingStreet(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>All Streets</h5>
|
||||
{loading ? (
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>Street Name</th>
|
||||
<th>Location</th>
|
||||
<th>Status</th>
|
||||
<th>Adopters</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{streets.length > 0 ? (
|
||||
streets.map((street) => (
|
||||
<tr key={street._id}>
|
||||
<td>{street.name}</td>
|
||||
<td>{street.location}</td>
|
||||
<td>
|
||||
<span className={`badge ${street.status === "adopted" ? "bg-success" : "bg-secondary"}`}>
|
||||
{street.status || "Not Adopted"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{street.adopters?.length || 0}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-warning me-2"
|
||||
onClick={() => setEditingStreet(street)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => deleteStreet(street._id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="5" className="text-center text-muted">
|
||||
No streets found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Rewards Tab
|
||||
const renderRewardsTab = () => (
|
||||
<div>
|
||||
<h4 className="mb-4">Rewards Management</h4>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header bg-success text-white">
|
||||
<h5 className="mb-0">{editingReward ? "Edit Reward" : "Create New Reward"}</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={editingReward ? updateReward : createReward}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Reward Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={editingReward?.name || newReward.name}
|
||||
onChange={(e) =>
|
||||
editingReward
|
||||
? setEditingReward({ ...editingReward, name: e.target.value })
|
||||
: setNewReward({ ...newReward, name: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Points Cost *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
value={editingReward?.pointsCost || newReward.pointsCost}
|
||||
onChange={(e) =>
|
||||
editingReward
|
||||
? setEditingReward({ ...editingReward, pointsCost: e.target.value })
|
||||
: setNewReward({ ...newReward, pointsCost: e.target.value })
|
||||
}
|
||||
required
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="rewardActive"
|
||||
checked={editingReward?.active || newReward.active}
|
||||
onChange={(e) =>
|
||||
editingReward
|
||||
? setEditingReward({ ...editingReward, active: e.target.checked })
|
||||
: setNewReward({ ...newReward, active: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="rewardActive">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-success me-2">
|
||||
{editingReward ? "Update Reward" : "Create Reward"}
|
||||
</button>
|
||||
{editingReward && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setEditingReward(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>All Rewards</h5>
|
||||
{loading ? (
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>Reward Name</th>
|
||||
<th>Points Cost</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rewards.length > 0 ? (
|
||||
rewards.map((reward) => (
|
||||
<tr key={reward._id}>
|
||||
<td>{reward.name}</td>
|
||||
<td>{reward.pointsCost}</td>
|
||||
<td>
|
||||
<span className={`badge ${reward.active ? "bg-success" : "bg-secondary"}`}>
|
||||
{reward.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className={`btn btn-sm me-2 ${reward.active ? "btn-warning" : "btn-info"}`}
|
||||
onClick={() => toggleRewardStatus(reward._id, reward.active)}
|
||||
>
|
||||
{reward.active ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-warning me-2"
|
||||
onClick={() => setEditingReward(reward)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => deleteReward(reward._id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="4" className="text-center text-muted">
|
||||
No rewards found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Content Tab
|
||||
const renderContentTab = () => (
|
||||
<div>
|
||||
<h4 className="mb-4">Content Moderation</h4>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Recent Posts</h5>
|
||||
{loading ? (
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : (
|
||||
<div className="list-group">
|
||||
{posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<div key={post._id} className="list-group-item">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div className="flex-grow-1">
|
||||
<h6 className="mb-1">
|
||||
<strong>{post.author?.name || "Unknown"}</strong>
|
||||
</h6>
|
||||
<p className="mb-1 text-muted">
|
||||
{post.content?.substring(0, 100)}...
|
||||
</p>
|
||||
<small className="text-muted">
|
||||
{new Date(post.createdAt).toLocaleDateString()}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => deletePost(post._id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-muted py-4">
|
||||
No posts found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5>Recent Events</h5>
|
||||
<div className="list-group">
|
||||
{events.length > 0 ? (
|
||||
events.map((event) => (
|
||||
<div key={event._id} className="list-group-item">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div className="flex-grow-1">
|
||||
<h6 className="mb-1">
|
||||
<strong>{event.title || event.name}</strong>
|
||||
</h6>
|
||||
<p className="mb-1 text-muted">
|
||||
{event.description?.substring(0, 100)}...
|
||||
</p>
|
||||
<small className="text-muted">
|
||||
{new Date(event.date || event.createdAt).toLocaleDateString()}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => deleteEvent(event._id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-muted py-4">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<h2 className="mb-4">Admin Dashboard</h2>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<ul className="nav nav-tabs mb-4" role="tablist">
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "overview" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("overview")}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "users" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("users")}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "streets" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("streets")}
|
||||
>
|
||||
Streets
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "rewards" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("rewards")}
|
||||
>
|
||||
Rewards
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "content" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("content")}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="tab-content">
|
||||
{activeTab === "overview" && renderOverviewTab()}
|
||||
{activeTab === "users" && renderUsersTab()}
|
||||
{activeTab === "streets" && renderStreetsTab()}
|
||||
{activeTab === "rewards" && renderRewardsTab()}
|
||||
{activeTab === "content" && renderContentTab()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
33
frontend/src/components/AdminRoute.js
Normal file
33
frontend/src/components/AdminRoute.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useContext } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
const AdminRoute = ({ children }) => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
|
||||
// Show loading state while checking authentication
|
||||
if (auth.loading) {
|
||||
return (
|
||||
<div className="d-flex justify-content-center align-items-center" style={{ height: "100vh" }}>
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!auth.isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Redirect to home if not admin
|
||||
if (!auth.user?.isAdmin) {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
|
||||
// Render the protected admin component
|
||||
return children;
|
||||
};
|
||||
|
||||
export default AdminRoute;
|
||||
@@ -31,12 +31,19 @@ const Navbar = () => {
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/profile" data-testid="nav-profile">Profile</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/premium" data-testid="nav-premium">Premium</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a onClick={logout} href="#!" className="nav-link" data-testid="logout-button">Logout</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/premium" data-testid="nav-premium">Premium</Link>
|
||||
</li>
|
||||
{auth.user?.isAdmin && (
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link text-danger" to="/admin" data-testid="nav-admin">
|
||||
Admin
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
<li className="nav-item">
|
||||
<a onClick={logout} href="#!" className="nav-link" data-testid="logout-button">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user