Files
adopt-a-street/scripts/setup-couchdb.js
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

472 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// Setup module path to include backend node_modules
const path = require('path');
const backendPath = path.join(__dirname, '..', 'backend');
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') });
// 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;