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:
William Valentin
2025-12-06 13:36:15 -08:00
parent 71c1d82e0e
commit fc23f4d098
15 changed files with 1159 additions and 22 deletions
+19
View File
@@ -0,0 +1,19 @@
const User = require("../models/User");
module.exports = async function (req, res, next) {
try {
const user = await User.findById(req.user.id);
if (!user || !user.isAdmin) {
return res.status(403).json({
success: false,
msg: "Access denied. Admin privileges required."
});
}
next();
} catch (err) {
console.error("Admin auth error:", err.message);
return res.status(500).json({ success: false, msg: "Server error" });
}
};
+8 -6
View File
@@ -47,8 +47,9 @@ class User {
// --- Gamification & App Data ---
this.isPremium = data.isPremium || false;
this.points = Math.max(0, data.points || 0);
this.isPremium = data.isPremium || false;
this.isAdmin = data.isAdmin || false;
this.points = Math.max(0, data.points || 0);
this.adoptedStreets = data.adoptedStreets || [];
this.completedTasks = data.completedTasks || [];
this.posts = data.posts || [];
@@ -205,10 +206,11 @@ class User {
location: this.location,
website: this.website,
social: this.social,
privacySettings: this.privacySettings,
preferences: this.preferences,
isPremium: this.isPremium,
points: this.points,
privacySettings: this.privacySettings,
preferences: this.preferences,
isPremium: this.isPremium,
isAdmin: this.isAdmin,
points: this.points,
adoptedStreets: this.adoptedStreets,
completedTasks: this.completedTasks,
posts: this.posts,
+5
View File
@@ -1,5 +1,6 @@
const express = require("express");
const auth = require("../middleware/auth");
const adminAuth = require("../middleware/adminAuth");
const { asyncHandler } = require("../middleware/errorHandler");
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
const couchdbService = require("../services/couchdbService");
@@ -77,6 +78,7 @@ const groupByTimePeriod = (data, groupBy = "day", dateField = "createdAt") => {
router.get(
"/overview",
auth,
adminAuth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { timeframe = "all" } = req.query;
@@ -249,6 +251,7 @@ router.get(
router.get(
"/activity",
auth,
adminAuth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { timeframe = "30d", groupBy = "day" } = req.query;
@@ -335,6 +338,7 @@ router.get(
router.get(
"/top-contributors",
auth,
adminAuth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { limit = 10, timeframe = "all", metric = "points" } = req.query;
@@ -472,6 +476,7 @@ router.get(
router.get(
"/street-stats",
auth,
adminAuth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { timeframe = "all" } = req.query;
+2
View File
@@ -1,5 +1,6 @@
const express = require("express");
const auth = require("../middleware/auth");
const adminAuth = require("../middleware/adminAuth");
const { asyncHandler } = require("../middleware/errorHandler");
const { getCacheStats, clearCache } = require("../middleware/cache");
@@ -31,6 +32,7 @@ router.get(
router.delete(
"/",
auth,
adminAuth,
asyncHandler(async (req, res) => {
clearCache();
res.json({
+6
View File
@@ -1,6 +1,7 @@
const express = require("express");
const Reward = require("../models/Reward");
const auth = require("../middleware/auth");
const adminAuth = require("../middleware/adminAuth");
const { asyncHandler } = require("../middleware/errorHandler");
const {
createRewardValidation,
@@ -28,6 +29,7 @@ router.get(
router.post(
"/",
auth,
adminAuth,
createRewardValidation,
asyncHandler(async (req, res) => {
const { name, description, cost, isPremium } = req.body;
@@ -102,6 +104,7 @@ router.get(
router.put(
"/:id",
auth,
adminAuth,
rewardIdValidation,
createRewardValidation,
asyncHandler(async (req, res) => {
@@ -126,6 +129,7 @@ router.put(
router.delete(
"/:id",
auth,
adminAuth,
rewardIdValidation,
asyncHandler(async (req, res) => {
const reward = await Reward.findById(req.params.id);
@@ -229,6 +233,7 @@ router.get(
router.patch(
"/:id/toggle",
auth,
adminAuth,
rewardIdValidation,
asyncHandler(async (req, res) => {
const updatedReward = await Reward.toggleActiveStatus(req.params.id);
@@ -240,6 +245,7 @@ router.patch(
router.post(
"/bulk",
auth,
adminAuth,
asyncHandler(async (req, res) => {
const { rewards } = req.body;
+2
View File
@@ -3,6 +3,7 @@ const Street = require("../models/Street");
const User = require("../models/User");
const couchdbService = require("../services/couchdbService");
const auth = require("../middleware/auth");
const adminAuth = require("../middleware/adminAuth");
const { asyncHandler } = require("../middleware/errorHandler");
const {
createStreetValidation,
@@ -103,6 +104,7 @@ router.get(
router.post(
"/",
auth,
adminAuth,
createStreetValidation,
asyncHandler(async (req, res) => {
const { name, location } = req.body;
+3
View File
@@ -29,3 +29,6 @@ data:
# OpenAI Configuration (optional - for AI features)
# Note: OPENAI_API_KEY should be in secrets.yaml
OPENAI_MODEL: "gpt-3.5-turbo"
# Admin Configuration
ADMIN_EMAIL: "will@wills-portal.com"
+66
View File
@@ -0,0 +1,66 @@
apiVersion: batch/v1
kind: Job
metadata:
name: couchdb-init
labels:
app: couchdb-init
spec:
backoffLimit: 3
template:
metadata:
labels:
app: couchdb-init
spec:
imagePullSecrets:
- name: regcred
initContainers:
- name: wait-for-couchdb
image: curlimages/curl:8.5.0
command:
- sh
- -c
- |
until curl -f -s http://adopt-a-street-couchdb:5984/_up > /dev/null 2>&1; do
echo "Waiting for CouchDB to be ready..."
sleep 3
done
echo "CouchDB is ready!"
containers:
- name: couchdb-init
image: gitea-http.taildb3494.ts.net/will/adopt-a-street-backend:latest
imagePullPolicy: Always
command: ["node", "scripts/setup-couchdb.js"]
envFrom:
- configMapRef:
name: adopt-a-street-config
- secretRef:
name: adopt-a-street-secrets
env:
- name: COUCHDB_USER
valueFrom:
secretKeyRef:
name: adopt-a-street-secrets
key: COUCHDB_USER
- name: COUCHDB_PASSWORD
valueFrom:
secretKeyRef:
name: adopt-a-street-secrets
key: COUCHDB_PASSWORD
- name: ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: adopt-a-street-secrets
key: ADMIN_EMAIL
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: adopt-a-street-secrets
key: ADMIN_PASSWORD
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
restartPolicy: Never
+2
View File
@@ -1,5 +1,7 @@
apiVersion: v1
data:
ADMIN_EMAIL: d2lsbEB3aWxscy1wb3J0YWwuY29t
ADMIN_PASSWORD: ZnJhY2s2NjY=
CLOUDINARY_API_KEY: ""
CLOUDINARY_API_SECRET: ""
CLOUDINARY_CLOUD_NAME: ""
+5
View File
@@ -22,6 +22,10 @@ stringData:
# OpenAI Configuration (optional - for AI features)
OPENAI_API_KEY: "your-openai-api-key"
# Admin User Configuration - CHANGE THESE IN PRODUCTION!
ADMIN_EMAIL: "admin@example.com" # Default admin user email
ADMIN_PASSWORD: "change-this-password" # Default admin user password
---
# IMPORTANT:
# 1. Copy this file to secrets.yaml
@@ -31,3 +35,4 @@ stringData:
# 5. Generate strong passwords for CouchDB using: openssl rand -base64 32
# 6. Non-sensitive config values (CLOUDINARY_CLOUD_NAME, STRIPE_PUBLISHABLE_KEY, OPENAI_MODEL)
# are in configmap.yaml
# 7. Set ADMIN_EMAIL and ADMIN_PASSWORD to create the default admin user at deployment
+5 -2
View File
@@ -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
View 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
View 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;
+13 -6
View File
@@ -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>
);
+81 -8
View File
@@ -367,14 +367,87 @@ class CouchDBSetup {
}
}
async run() {
try {
await this.initialize();
await this.createDatabase();
await this.createIndexes();
await this.createSecurityDocument();
await this.seedBadges();
await this.verifySetup();
async seedAdminUser() {
console.log('👤 Seeding admin user...');
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.log('⚠️ ADMIN_EMAIL or ADMIN_PASSWORD not set, skipping admin user creation');
return;
}
const db = this.nano.use(COUCHDB_DB_NAME);
// Check if admin user already exists
try {
const existing = await db.find({
selector: { type: 'user', email: ADMIN_EMAIL },
limit: 1
});
if (existing.docs.length > 0) {
const user = existing.docs[0];
if (!user.isAdmin) {
user.isAdmin = true;
user.updatedAt = new Date().toISOString();
await db.insert(user);
console.log('✅ Existing user promoted to admin');
} else {
console.log('️ Admin user already exists');
}
return;
}
} catch (error) {
// Continue with creation
}
// Create new admin user with hashed password
const bcrypt = require('bcryptjs');
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, salt);
const adminUser = {
_id: `user_admin_${Date.now()}`,
type: 'user',
name: 'Administrator',
email: ADMIN_EMAIL,
password: hashedPassword,
isAdmin: true,
isPremium: true,
points: 0,
avatar: null,
profilePicture: null,
bio: 'System Administrator',
location: '',
website: '',
social: { twitter: '', github: '', linkedin: '' },
privacySettings: { profileVisibility: 'private' },
preferences: { emailNotifications: true, pushNotifications: true, theme: 'light' },
adoptedStreets: [],
completedTasks: [],
posts: [],
events: [],
earnedBadges: [],
stats: { streetsAdopted: 0, tasksCompleted: 0, postsCreated: 0, eventsParticipated: 0, badgesEarned: 0 },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
await db.insert(adminUser);
console.log(`✅ Admin user created: ${ADMIN_EMAIL}`);
}
async run() {
try {
await this.initialize();
await this.createDatabase();
await this.createIndexes();
await this.createSecurityDocument();
await this.seedBadges();
await this.seedAdminUser();
await this.verifySetup();
console.log('\n🎉 CouchDB setup completed successfully!');
console.log(`\n📋 Connection Details:`);