diff --git a/backend/middleware/adminAuth.js b/backend/middleware/adminAuth.js new file mode 100644 index 0000000..8a2a3d1 --- /dev/null +++ b/backend/middleware/adminAuth.js @@ -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" }); + } +}; diff --git a/backend/models/User.js b/backend/models/User.js index f1ec5be..b115027 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -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, diff --git a/backend/routes/analytics.js b/backend/routes/analytics.js index 32d89d0..39a0462 100644 --- a/backend/routes/analytics.js +++ b/backend/routes/analytics.js @@ -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; diff --git a/backend/routes/cache.js b/backend/routes/cache.js index 8d8a646..d860561 100644 --- a/backend/routes/cache.js +++ b/backend/routes/cache.js @@ -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({ diff --git a/backend/routes/rewards.js b/backend/routes/rewards.js index daa70aa..2176790 100644 --- a/backend/routes/rewards.js +++ b/backend/routes/rewards.js @@ -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; diff --git a/backend/routes/streets.js b/backend/routes/streets.js index 624b11e..6d701f3 100644 --- a/backend/routes/streets.js +++ b/backend/routes/streets.js @@ -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; diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml index ab4043f..f5ccff6 100644 --- a/deploy/k8s/configmap.yaml +++ b/deploy/k8s/configmap.yaml @@ -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" diff --git a/deploy/k8s/couchdb-init-job.yaml b/deploy/k8s/couchdb-init-job.yaml new file mode 100644 index 0000000..9c0bf05 --- /dev/null +++ b/deploy/k8s/couchdb-init-job.yaml @@ -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 diff --git a/deploy/k8s/secrets.yaml b/deploy/k8s/secrets.yaml index 028b943..f7c078a 100644 --- a/deploy/k8s/secrets.yaml +++ b/deploy/k8s/secrets.yaml @@ -1,5 +1,7 @@ apiVersion: v1 data: + ADMIN_EMAIL: d2lsbEB3aWxscy1wb3J0YWwuY29t + ADMIN_PASSWORD: ZnJhY2s2NjY= CLOUDINARY_API_KEY: "" CLOUDINARY_API_SECRET: "" CLOUDINARY_CLOUD_NAME: "" diff --git a/deploy/k8s/secrets.yaml.example b/deploy/k8s/secrets.yaml.example index e3507de..32b2f61 100644 --- a/deploy/k8s/secrets.yaml.example +++ b/deploy/k8s/secrets.yaml.example @@ -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 diff --git a/frontend/src/App.js b/frontend/src/App.js index 7b3a998..affeaed 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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() { } /> } /> } /> - } /> - } /> + } /> + } /> + } /> { + 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 = () => ( +
+

Platform Statistics

+ {loading ? ( +
+
+ Loading... +
+
+ ) : error ? ( +
{error}
+ ) : statistics ? ( +
+
+
+
+
Total Users
+

{statistics.totalUsers || 0}

+
+
+
+
+
+
+
Adopted Streets
+

{statistics.totalStreets || 0}

+
+
+
+
+
+
+
Completed Tasks
+

{statistics.totalTasks || 0}

+
+
+
+
+
+
+
Active Events
+

{statistics.totalEvents || 0}

+
+
+
+
+
+
+
Total Posts
+

{statistics.totalPosts || 0}

+
+
+
+
+ ) : null} + +

Quick Actions

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ); + + // Render Users Tab + const renderUsersTab = () => ( +
+

User Management

+
+ setSearchUsers(e.target.value)} + /> +
+ + {loading ? ( +
+
+ Loading... +
+
+ ) : error ? ( +
{error}
+ ) : ( +
+ + + + + + + + + + + {filteredUsers.length > 0 ? ( + filteredUsers.map((user) => ( + + + + + + + )) + ) : ( + + + + )} + +
NameEmailAdmin StatusActions
{user.name}{user.email} + + {user.isAdmin ? "Admin" : "User"} + + + + +
+ No users found +
+
+ )} +
+ ); + + // Render Streets Tab + const renderStreetsTab = () => ( +
+

Street Management

+ +
+
+
{editingStreet ? "Edit Street" : "Create New Street"}
+
+
+
+
+ + + editingStreet + ? setEditingStreet({ ...editingStreet, name: e.target.value }) + : setNewStreet({ ...newStreet, name: e.target.value }) + } + required + /> +
+
+ + + editingStreet + ? setEditingStreet({ ...editingStreet, location: e.target.value }) + : setNewStreet({ ...newStreet, location: e.target.value }) + } + required + /> +
+
+ +