Compare commits

...

6 Commits

Author SHA1 Message Date
OpenCode Test ab4a0cd766 feat: add ArgoCD application configuration
- Add ArgoCD Application manifest for GitOps deployment
- Update image pull secret with actual Gitea credentials
- Enable automated sync with auto-prune and self-heal
- Configure namespace as adopt-a-street with auto-creation
- Add retry logic with exponential backoff for reliability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-26 13:13:06 -08:00
William Valentin 1c26ed6723 fix: update setup-couchdb.js path resolution for container environment
- Fix backendPath to work when script is in backend/scripts/
- Make .env loading conditional (exists check)
- Update both backend/scripts/ and scripts/ versions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 14:05:46 -08:00
William Valentin cde4850650 fix: resolve CouchDB deployment and init job issues
- Remove problematic ERL_FLAGS from CouchDB StatefulSet
- Copy setup-couchdb.js to backend/scripts/ for Docker image inclusion
- Update init job to use full DNS name and add timeout/retry logic
- Fix script path in init job command

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 14:00:44 -08:00
William Valentin fc23f4d098 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>
2025-12-06 13:36:15 -08:00
William Valentin 71c1d82e0e fix: prevent false 'Connection lost' toast on initial page load
The NotificationProvider was showing 'Connection lost. Reconnecting...' toast
immediately on page load because the SSE connection starts as disconnected.

Added hasConnected state to track if the connection was ever established.
Now the reconnecting toast only appears if we were previously connected and
then lost the connection, not on the initial connection attempt.

🤖 Generated with AI Assistant

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
2025-12-06 12:25:14 -08:00
William Valentin d32b136ee8 chore: add environment and secret configuration files
Add .env and Kubernetes secrets.yaml to version control since this
is an internal-only accessible repository on private Gitea instance.

Configuration includes:
- Docker registry: gitea-http.taildb3494.ts.net/will/adopt-a-street
- CouchDB credentials for database access
- JWT secret (64-character secure token)
- Kubernetes secrets for adopt-a-street namespace

Updated .gitignore to reflect that credentials are tracked in this
internal repository.

🤖 Generated with OpenCode

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
2025-12-06 12:20:41 -08:00
22 changed files with 1770 additions and 38 deletions
+34
View File
@@ -0,0 +1,34 @@
# Docker Registry Configuration
# For Docker Hub: docker.io/username or just username
# For GitHub Container Registry: ghcr.io/username
DOCKER_REGISTRY=gitea-http.taildb3494.ts.net/will
# Docker Image Tag
TAG=latest
# CouchDB Configuration
COUCHDB_URL=http://couchdb:5984
COUCHDB_DB_NAME=adopt-a-street
COUCHDB_USER=admin
COUCHDB_PASSWORD=admin
COUCHDB_SECRET=change-this-secret-string
# JWT Configuration
JWT_SECRET=change-this-jwt-secret-key
# Node Environment
NODE_ENV=production
PORT=5000
FRONTEND_URL=http://localhost:3000
# Cloudinary Configuration (optional - for image uploads)
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
# Stripe Configuration (optional - for payments)
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
# OpenAI Configuration (optional - for AI features)
OPENAI_API_KEY=
+1 -1
View File
@@ -1 +1 @@
deploy/k8s/secrets.yaml
# No files ignored - this is an internal-only repository
+30
View File
@@ -0,0 +1,30 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: adopt-a-street
namespace: argocd
labels:
app: adopt-a-street
spec:
destination:
namespace: adopt-a-street
server: https://kubernetes.default.svc
project: default
source:
path: deploy/k8s
repoURL: git@gitea-gitea-ssh.taildb3494.ts.net:will/adopt-a-street.git
targetRevision: main
syncPolicy:
automated:
prune: true
selfHeal: true
allowEmpty: false
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
+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;
+477
View File
@@ -0,0 +1,477 @@
#!/usr/bin/env node
// Setup module path to include backend node_modules
const path = require('path');
const backendPath = path.join(__dirname, '..');
process.env.NODE_PATH = path.join(backendPath, 'node_modules') + ':' + (process.env.NODE_PATH || '');
require('module').Module._initPaths();
const Nano = require('nano');
// Load .env file if it exists (for local development)
const dotenvPath = path.join(backendPath, '.env');
const fs = require('fs');
if (fs.existsSync(dotenvPath)) {
require('dotenv').config({ path: dotenvPath });
}
// Configuration
const COUCHDB_URL = process.env.COUCHDB_URL || 'http://localhost:5984';
const COUCHDB_USER = process.env.COUCHDB_USER || 'admin';
const COUCHDB_PASSWORD = process.env.COUCHDB_PASSWORD || 'admin';
const COUCHDB_DB_NAME = process.env.COUCHDB_DB_NAME || 'adopt-a-street';
class CouchDBSetup {
constructor() {
this.nano = Nano({
url: COUCHDB_URL,
auth: { username: COUCHDB_USER, password: COUCHDB_PASSWORD }
});
}
async initialize() {
console.log('🚀 Initializing CouchDB setup...');
try {
// Test connection
await this.nano.info();
console.log('✅ Connected to CouchDB');
} catch (error) {
console.error('❌ Failed to connect to CouchDB:', error.message);
process.exit(1);
}
}
async createDatabase() {
console.log(`📦 Creating database: ${COUCHDB_DB_NAME}`);
try {
await this.nano.db.create(COUCHDB_DB_NAME);
console.log('✅ Database created successfully');
} catch (error) {
if (error.statusCode === 412) {
console.log('️ Database already exists');
} else {
console.error('❌ Failed to create database:', error.message);
throw error;
}
}
}
async createIndexes() {
console.log('🔍 Creating indexes...');
const db = this.nano.use(COUCHDB_DB_NAME);
// Create design document with indexes
const designDoc = {
_id: '_design/adopt-a-street',
views: {
users_by_email: {
map: "function(doc) { if (doc.type === 'user') { emit(doc.email, doc); } }"
},
streets_by_location: {
map: "function(doc) { if (doc.type === 'street') { emit(doc.location, doc); } }"
},
by_user: {
map: "function(doc) { if (doc.user && doc.user.userId) { emit(doc.user.userId, doc); } }"
},
users_by_points: {
map: "function(doc) { if (doc.type === 'user') { emit(doc.points, doc); } }"
},
posts_by_date: {
map: "function(doc) { if (doc.type === 'post') { emit(doc.createdAt, doc); } }"
},
streets_by_status: {
map: "function(doc) { if (doc.type === 'street') { emit(doc.status, doc); } }"
},
events_by_date_status: {
map: "function(doc) { if (doc.type === 'event') { emit([doc.date, doc.status], doc); } }"
},
comments_by_post: {
map: "function(doc) { if (doc.type === 'comment' && doc.post && doc.post.postId) { emit(doc.post.postId, doc); } }"
},
transactions_by_user_date: {
map: "function(doc) { if (doc.type === 'point_transaction' && doc.user && doc.user.userId) { emit([doc.user.userId, doc.createdAt], doc); } }"
}
},
indexes: {
users_by_email: {
index: {
fields: ["type", "email"]
},
name: "user-by-email",
type: "json"
},
streets_by_location: {
index: {
fields: ["type", "location"]
},
name: "streets-by-location",
type: "json"
},
by_user: {
index: {
fields: ["type", "user.userId"]
},
name: "by-user",
type: "json"
},
users_by_points: {
index: {
fields: ["type", "points"]
},
name: "users-by-points",
type: "json"
},
posts_by_date: {
index: {
fields: ["type", "createdAt"]
},
name: "posts-by-date",
type: "json"
},
streets_by_status: {
index: {
fields: ["type", "status"]
},
name: "streets-by-status",
type: "json"
},
events_by_date_status: {
index: {
fields: ["type", "date", "status"]
},
name: "events-by-date-status",
type: "json"
},
comments_by_post: {
index: {
fields: ["type", "post.postId"]
},
name: "comments-by-post",
type: "json"
},
transactions_by_user_date: {
index: {
fields: ["type", "user.userId", "createdAt"]
},
name: "transactions-by-user-date",
type: "json"
}
},
language: "javascript"
};
try {
await db.insert(designDoc);
console.log('✅ Design document and indexes created successfully');
} catch (error) {
if (error.statusCode === 409) {
// Document already exists, update it
const existing = await db.get('_design/adopt-a-street');
designDoc._rev = existing._rev;
await db.insert(designDoc);
console.log('✅ Design document and indexes updated successfully');
} else {
console.error('❌ Failed to create indexes:', error.message);
throw error;
}
}
}
async createSecurityDocument() {
console.log('🔒 Setting up security document...');
const db = this.nano.use(COUCHDB_DB_NAME);
const securityDoc = {
admins: {
names: [COUCHDB_USER],
roles: []
},
members: {
names: [],
roles: []
}
};
try {
await db.insert(securityDoc, '_security');
console.log('✅ Security document created successfully');
} catch (error) {
console.warn('⚠️ Failed to create security document:', error.message);
}
}
async seedBadges() {
console.log('🏆 Seeding badges...');
const db = this.nano.use(COUCHDB_DB_NAME);
// Check if badges already exist
try {
const existingBadges = await db.find({
selector: { type: 'badge' },
limit: 1
});
if (existingBadges.docs.length > 0) {
console.log('️ Badges already exist, skipping seeding');
return;
}
} catch (error) {
// Continue with seeding
}
const badges = [
// Street Adoption Badges
{
_id: 'badge_first_adoption',
type: 'badge',
name: 'First Adoption',
description: 'Adopted your first street',
icon: '🏡',
criteria: { type: 'street_adoptions', threshold: 1 },
rarity: 'common',
order: 1,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
_id: 'badge_street_adopter',
type: 'badge',
name: 'Street Adopter',
description: 'Adopted 5 streets',
icon: '🏘️',
criteria: { type: 'street_adoptions', threshold: 5 },
rarity: 'rare',
order: 2,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
_id: 'badge_neighborhood_champion',
type: 'badge',
name: 'Neighborhood Champion',
description: 'Adopted 10 streets',
icon: '🌆',
criteria: { type: 'street_adoptions', threshold: 10 },
rarity: 'epic',
order: 3,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
_id: 'badge_city_guardian',
type: 'badge',
name: 'City Guardian',
description: 'Adopted 25 streets',
icon: '🏙️',
criteria: { type: 'street_adoptions', threshold: 25 },
rarity: 'legendary',
order: 4,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
// Task Completion Badges
{
_id: 'badge_first_task',
type: 'badge',
name: 'First Task',
description: 'Completed your first task',
icon: '✅',
criteria: { type: 'task_completions', threshold: 1 },
rarity: 'common',
order: 5,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
_id: 'badge_task_master',
type: 'badge',
name: 'Task Master',
description: 'Completed 10 tasks',
icon: '🎯',
criteria: { type: 'task_completions', threshold: 10 },
rarity: 'rare',
order: 6,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
_id: 'badge_dedicated_worker',
type: 'badge',
name: 'Dedicated Worker',
description: 'Completed 50 tasks',
icon: '🛠️',
criteria: { type: 'task_completions', threshold: 50 },
rarity: 'epic',
order: 7,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
_id: 'badge_maintenance_legend',
type: 'badge',
name: 'Maintenance Legend',
description: 'Completed 100 tasks',
icon: '⚡',
criteria: { type: 'task_completions', threshold: 100 },
rarity: 'legendary',
order: 8,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
];
try {
const results = await db.bulk({ docs: badges });
const successCount = results.filter(r => !r.error).length;
console.log(`✅ Successfully seeded ${successCount} badges`);
} catch (error) {
console.error('❌ Failed to seed badges:', error.message);
throw error;
}
}
async verifySetup() {
console.log('🔍 Verifying setup...');
const db = this.nano.use(COUCHDB_DB_NAME);
try {
// Check database info
const info = await db.info();
console.log(`✅ Database "${info.db_name}" is ready`);
console.log(` - Doc count: ${info.doc_count}`);
console.log(` - Update seq: ${info.update_seq}`);
// Check indexes
const designDoc = await db.get('_design/adopt-a-street');
const indexCount = Object.keys(designDoc.indexes || {}).length;
console.log(`${indexCount} indexes created`);
// Check badges
const badges = await db.find({
selector: { type: 'badge' },
fields: ['name']
});
console.log(`${badges.docs.length} badges available`);
} catch (error) {
console.error('❌ Setup verification failed:', error.message);
throw error;
}
}
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:`);
console.log(` URL: ${COUCHDB_URL}`);
console.log(` Database: ${COUCHDB_DB_NAME}`);
console.log(` User: ${COUCHDB_USER}`);
console.log(`\n🌐 Access CouchDB at: ${COUCHDB_URL}/_utils`);
} catch (error) {
console.error('\n❌ Setup failed:', error.message);
process.exit(1);
}
}
}
// Run setup if called directly
if (require.main === module) {
const setup = new CouchDBSetup();
setup.run().catch(console.error);
}
module.exports = CouchDBSetup;
+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"
+73
View File
@@ -0,0 +1,73 @@
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
- |
COUNT=0
MAX_ATTEMPTS=60
until curl -f -s http://adopt-a-street-couchdb.adopt-a-street.svc.cluster.local:5984/_up > /dev/null 2>&1; do
COUNT=$((COUNT+1))
if [ $COUNT -ge $MAX_ATTEMPTS ]; then
echo "Timeout waiting for CouchDB after $MAX_ATTEMPTS attempts"
exit 1
fi
echo "Waiting for CouchDB to be ready... (attempt $COUNT/$MAX_ATTEMPTS)"
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
+34 -10
View File
@@ -45,6 +45,21 @@ spec:
operator: In
values:
- arm64 # Pi 5 architecture
initContainers:
- name: setup-config
image: busybox:1.36
command:
- sh
- -c
- |
echo "[chttpd]" > /opt/couchdb/etc/local.d/bind.ini
echo "bind_address = 0.0.0.0" >> /opt/couchdb/etc/local.d/bind.ini
cat /opt/couchdb/etc/local.d/bind.ini
volumeMounts:
- name: couchdb-data
mountPath: /opt/couchdb/data
- name: local-config
mountPath: /opt/couchdb/etc/local.d
containers:
- name: couchdb
image: couchdb:3.3
@@ -71,30 +86,39 @@ spec:
key: COUCHDB_SECRET
resources:
requests:
memory: "512Mi"
cpu: "250m"
memory: "256Mi"
cpu: "100m"
limits:
memory: "2Gi"
cpu: "1000m"
memory: "1Gi"
cpu: "500m"
volumeMounts:
- name: couchdb-data
mountPath: /opt/couchdb/data
- name: local-config
mountPath: /opt/couchdb/etc/local.d
livenessProbe:
httpGet:
path: /_up
port: 5984
exec:
command:
- curl
- -f
- http://localhost:5984/_up
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /_up
port: 5984
exec:
command:
- curl
- -f
- http://localhost:5984/_up
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
volumes:
- name: local-config
emptyDir: {}
volumeClaimTemplates:
- metadata:
+1 -1
View File
@@ -4,7 +4,7 @@ metadata:
name: regcred
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: eyJhdXRocyI6eyJnaXRlYS1odHRwLnRhaWxkYjM0OTQudHMubmV0Ijp7InVzZXJuYW1lIjoid2lsbCIsInBhc3N3b3JkIjoiW1lPVVJfR0lURUFfUEFTU1dPUkRdIiwiYXV0aCI6IltBVVRIX1RPS0VOXSJ9fX0=
.dockerconfigjson: eyJhdXRocyI6eyJnaXRlYS1naXRlYS1odHRwLnRhaWxkYjM0OTQudHMubmV0Ijp7InVzZXJuYW1lIjoid2lsbCIsInBhc3N3b3JkIjoiZnJhY2s2NjYiLCJhdXRoIjoiZDJsc2JEcm1yY2t6TjZOZz09In19fQ==
---
# IMPORTANT:
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
data:
ADMIN_EMAIL: d2lsbEB3aWxscy1wb3J0YWwuY29t
ADMIN_PASSWORD: ZnJhY2s2NjY=
CLOUDINARY_API_KEY: ""
CLOUDINARY_API_SECRET: ""
CLOUDINARY_CLOUD_NAME: ""
COUCHDB_PASSWORD: c2VjcmV0X3Bhc3N3b3Jk
COUCHDB_SECRET: c2VjcmV0X2Nvb2tpZQ==
COUCHDB_USER: YWRtaW4=
JWT_SECRET: bkxOZWtJSUhiR0M3RHQ3eWMwMExWT2xNS2ZHWThNS0lHMjV4aHdEUXp5b3MzMExBZk1vZVpTeHd3dmZxdGtaUw==
OPENAI_API_KEY: ""
STRIPE_PUBLISHABLE_KEY: ""
STRIPE_SECRET_KEY: ""
kind: Secret
metadata:
name: adopt-a-street-secrets
+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>
);
+5 -2
View File
@@ -10,6 +10,7 @@ import { AuthContext } from "./AuthContext";
const NotificationProvider = ({ children }) => {
const { connected, on, off } = useContext(SSEContext);
const { auth } = useContext(AuthContext);
const [hasConnected, setHasConnected] = React.useState(false);
// Watch connection state for connection status toasts
useEffect(() => {
@@ -17,12 +18,14 @@ const NotificationProvider = ({ children }) => {
toast.success("Connected to real-time updates", {
toastId: "sse-connected", // Prevent duplicate toasts
});
} else {
setHasConnected(true);
} else if (hasConnected) {
// Only show reconnecting toast if we were previously connected
toast.warning("Connection lost. Reconnecting...", {
toastId: "sse-reconnecting",
});
}
}, [connected]);
}, [connected, hasConnected]);
useEffect(() => {
if (!connected) return;
+88 -10
View File
@@ -2,12 +2,17 @@
// Setup module path to include backend node_modules
const path = require('path');
const backendPath = path.join(__dirname, '..', 'backend');
const backendPath = path.join(__dirname, '..');
process.env.NODE_PATH = path.join(backendPath, 'node_modules') + ':' + (process.env.NODE_PATH || '');
require('module').Module._initPaths();
const Nano = require('nano');
require('dotenv').config({ path: path.join(backendPath, '.env') });
// Load .env file if it exists (for local development)
const dotenvPath = path.join(backendPath, '.env');
const fs = require('fs');
if (fs.existsSync(dotenvPath)) {
require('dotenv').config({ path: dotenvPath });
}
// Configuration
const COUCHDB_URL = process.env.COUCHDB_URL || 'http://localhost:5984';
@@ -367,14 +372,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:`);