Backup: 2025-06-07 18:32 - Production setup complete

[Restore from backup: vip-coordinator-backup-2025-06-07-18-32-production-setup-complete]
This commit is contained in:
2025-06-07 18:32:00 +02:00
parent aa900505b9
commit ae3702c3b1
32 changed files with 2120 additions and 1494 deletions

29
.env.prod Normal file
View File

@@ -0,0 +1,29 @@
# Production Environment Configuration - SECURE VALUES
# Database Configuration
DB_PASSWORD=VipCoord2025SecureDB!
# Domain Configuration
DOMAIN=bsa.madeamess.online
VITE_API_URL=https://api.bsa.madeamess.online
# Authentication Configuration (Secure production keys)
JWT_SECRET=VipCoord2025JwtSecretKey8f9a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z
SESSION_SECRET=VipCoord2025SessionSecret9g8f7e6d5c4b3a2z1y0x9w8v7u6t5s4r3q2p1o0n9m8l7k6j5i4h3g2f1e
# Google OAuth Configuration
GOOGLE_CLIENT_ID=308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=https://api.bsa.madeamess.online/auth/google/callback
# Frontend URL
FRONTEND_URL=https://bsa.madeamess.online
# Flight API Configuration
AVIATIONSTACK_API_KEY=your-aviationstack-api-key
# Admin Configuration
ADMIN_PASSWORD=VipAdmin2025Secure!
# Port Configuration
PORT=3000

30
.env.production Normal file
View File

@@ -0,0 +1,30 @@
# Production Environment Configuration
# Copy this file to .env.prod and update the values for your production deployment
# Database Configuration
DB_PASSWORD=your-secure-database-password-here
# Domain Configuration
DOMAIN=bsa.madeamess.online
VITE_API_URL=https://api.bsa.madeamess.online/api
# Authentication Configuration (Generate new secure keys for production)
JWT_SECRET=your-super-secure-jwt-secret-key-change-in-production-12345
SESSION_SECRET=your-super-secure-session-secret-change-in-production-67890
# Google OAuth Configuration
GOOGLE_CLIENT_ID=308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=https://api.bsa.madeamess.online/auth/google/callback
# Frontend URL
FRONTEND_URL=https://bsa.madeamess.online
# Flight API Configuration
AVIATIONSTACK_API_KEY=your-aviationstack-api-key
# Admin Configuration
ADMIN_PASSWORD=your-secure-admin-password
# Port Configuration
PORT=3000

View File

@@ -0,0 +1,173 @@
# VIP Coordinator Documentation Cleanup - COMPLETED ✅
## 🎉 Complete Documentation Cleanup Successfully Finished
The VIP Coordinator project has been **completely cleaned up and modernized**. We've streamlined from **30+ files** down to **10 essential files**, removing all outdated documentation and redundant scripts.
## 📊 Final Results
### Before Cleanup (30+ files)
- **9 OAuth setup guides** - Multiple confusing, outdated approaches
- **8 Test data scripts** - External scripts for data population
- **3 One-time utility scripts** - API testing and migration scripts
- **8 Redundant documentation** - User management, troubleshooting, RBAC docs
- **2 Database migration docs** - Completed migration summaries
- **Scattered information** across many files
### After Cleanup (10 files)
- **1 Setup guide** - Single, comprehensive SETUP_GUIDE.md
- **1 Project overview** - Modern README.md with current features
- **1 API guide** - Detailed README-API.md
- **2 API documentation files** - Interactive Swagger UI and OpenAPI spec
- **3 Docker configuration files** - Development and production environments
- **1 Development tool** - Makefile for commands
- **2 Code directories** - backend/ and frontend/
## ✅ Total Files Removed: 28 files
### OAuth Documentation (9 files) ❌ REMOVED
- CORRECTED_GOOGLE_OAUTH_SETUP.md
- GOOGLE_OAUTH_DOMAIN_SETUP.md
- GOOGLE_OAUTH_QUICK_SETUP.md
- GOOGLE_OAUTH_SETUP.md
- OAUTH_CALLBACK_FIX_SUMMARY.md
- OAUTH_FRONTEND_ONLY_SETUP.md
- REVERSE_PROXY_OAUTH_SETUP.md
- SIMPLE_OAUTH_SETUP.md
- WEB_SERVER_PROXY_SETUP.md
### Test Data Scripts (8 files) ❌ REMOVED
*Reason: Built into admin dashboard UI*
- populate-events-dynamic.js
- populate-events-dynamic.sh
- populate-events.js
- populate-events.sh
- populate-test-data.js
- populate-test-data.sh
- populate-vips.js
- quick-populate-events.sh
### One-Time Utility Scripts (3 files) ❌ REMOVED
*Reason: No longer needed*
- test-aviationstack-endpoints.js (hardcoded API key, one-time testing)
- test-flight-api.js (redundant with admin dashboard API testing)
- update-departments.js (one-time migration script, already run)
### Redundant Documentation (8 files) ❌ REMOVED
- DATABASE_MIGRATION_SUMMARY.md
- POSTGRESQL_USER_MANAGEMENT.md
- SIMPLE_USER_MANAGEMENT.md
- USER_MANAGEMENT_RECOMMENDATIONS.md
- DOCKER_TROUBLESHOOTING.md
- PERMISSION_ISSUES_FIXED.md
- PORT_3000_SETUP_GUIDE.md
- ROLE_BASED_ACCESS_CONTROL.md
## 📚 Essential Files Preserved (10 files)
### Core Documentation ✅
1. **README.md** - Modern project overview with current features
2. **SETUP_GUIDE.md** - Comprehensive setup guide with Google OAuth
3. **README-API.md** - Detailed API documentation and examples
### API Documentation ✅
4. **api-docs.html** - Interactive Swagger UI documentation
5. **api-documentation.yaml** - OpenAPI specification
### Development Configuration ✅
6. **Makefile** - Development commands and workflows
7. **docker-compose.dev.yml** - Development environment
8. **docker-compose.prod.yml** - Production environment
### Project Structure ✅
9. **backend/** - Complete Node.js API server
10. **frontend/** - Complete React application
## 🚀 Key Improvements Achieved
### 1. **Simplified Setup Process**
- **Before**: 9+ OAuth guides with conflicting instructions
- **After**: Single SETUP_GUIDE.md with clear, step-by-step Google OAuth setup
### 2. **Modernized Test Data Management**
- **Before**: 8 external scripts requiring manual execution
- **After**: Built-in admin dashboard with one-click test data creation/removal
### 3. **Streamlined Documentation Maintenance**
- **Before**: 28+ files to keep updated
- **After**: 3 core documentation files (90% reduction in maintenance)
### 4. **Accurate System Representation**
- **Before**: Outdated documentation scattered across many files
- **After**: Current documentation reflecting JWT + Google OAuth architecture
### 5. **Clean Project Structure**
- **Before**: Root directory cluttered with 30+ files
- **After**: Clean, organized structure with only essential files
## 🎯 Current System (Properly Documented)
### Authentication System ✅
- **JWT-based authentication** with Google OAuth
- **Role-based access control**: Administrator, Coordinator, Driver
- **User approval system** for new registrations
- **Simple setup** documented in SETUP_GUIDE.md
### Test Data Management ✅
- **Built-in admin dashboard** for test data creation
- **One-click VIP generation** (20 diverse test VIPs with full schedules)
- **Easy cleanup** - remove all test data with one click
- **No external scripts needed**
### API Documentation ✅
- **Interactive Swagger UI** at `/api-docs.html`
- **"Try it out" functionality** for testing endpoints
- **Comprehensive API guide** in README-API.md
### Development Workflow ✅
- **Single command setup**: `make dev`
- **Docker-based development** with automatic database initialization
- **Clear troubleshooting** in SETUP_GUIDE.md
## 📋 Developer Experience
### New Developer Onboarding
1. **Clone repository**
2. **Follow SETUP_GUIDE.md** (single source of truth)
3. **Run `make dev`** (starts everything)
4. **Configure Google OAuth** (clear instructions)
5. **Use admin dashboard** for test data (no scripts)
6. **Access API docs** at localhost:3000/api-docs.html
### Documentation Maintenance
- **3 files to maintain** (vs. 28+ before)
- **No redundant information**
- **Clear ownership** of each documentation area
## 🎉 Success Metrics
-**28 files removed** (74% reduction)
-**All essential functionality preserved**
-**Test data management modernized**
-**Single, clear setup path established**
-**Documentation reflects current architecture**
-**Dramatically improved developer experience**
-**Massive reduction in maintenance burden**
## 🔮 Future Maintenance
### What to Keep Updated
1. **README.md** - Project overview and features
2. **SETUP_GUIDE.md** - Setup instructions and troubleshooting
3. **README-API.md** - API documentation and examples
### What's Self-Maintaining
- **api-docs.html** - Generated from OpenAPI spec
- **Test data** - Built into admin dashboard
- **OAuth setup** - Simplified to basic Google OAuth
---
**The VIP Coordinator project now has clean, current, and maintainable documentation that accurately reflects the modern system architecture!** 🚀
**Total Impact**: From 30+ files to 10 essential files (74% reduction) while significantly improving functionality and developer experience.

269
README.md
View File

@@ -1,106 +1,141 @@
# VIP Coordinator Dashboard
# VIP Coordinator
A comprehensive web application for managing VIP logistics, driver assignments, and real-time tracking during events.
A comprehensive web application for managing VIP logistics, driver assignments, and real-time tracking with Google OAuth authentication and role-based access control.
## Features
## Features
- **VIP Management**: Create, edit, and manage VIP profiles with flight information and schedules
- **Driver Coordination**: Assign and track drivers with real-time location updates
- **Flight Tracking**: Monitor flight status and arrival times
- **Schedule Management**: Organize VIP itineraries and driver assignments
- **Real-time Dashboard**: Overview of all active VIPs and available drivers
### 🔐 Authentication & User Management
- **Google OAuth Integration**: Secure login with Google accounts
- **Role-Based Access Control**: Administrator, Coordinator, and Driver roles
- **User Approval System**: Admin approval required for new users
- **JWT-Based Authentication**: Stateless, secure token system
## Tech Stack
### 👥 VIP Management
- **Complete VIP Profiles**: Name, organization, department, transport details
- **Multi-Flight Support**: Handle complex itineraries with multiple flights
- **Department Organization**: Office of Development and Admin departments
- **Schedule Management**: Event scheduling with conflict detection
- **Real-time Flight Tracking**: Automatic flight status updates
### 🚗 Driver Coordination
- **Driver Management**: Create and manage driver profiles
- **Availability Checking**: Real-time conflict detection
- **Schedule Assignment**: Assign drivers to VIP events
- **Department-Based Organization**: Organize drivers by department
### ✈️ Flight Integration
- **Real-time Flight Data**: Integration with AviationStack API
- **Automatic Tracking**: Scheduled flight status updates
- **Multi-Flight Support**: Handle complex travel itineraries
- **Flight Validation**: Verify flight numbers and dates
### 📊 Advanced Features
- **Interactive API Documentation**: Swagger UI with "Try it out" functionality
- **Schedule Validation**: Prevent conflicts and overlapping assignments
- **Comprehensive Logging**: Detailed system activity tracking
- **Docker Containerization**: Easy deployment and development
## 🏗️ Architecture
### Backend
- Node.js with Express.js
- TypeScript for type safety
- PostgreSQL database
- Redis for caching and real-time updates
- Docker containerization
- **Node.js + Express.js**: RESTful API server
- **TypeScript**: Full type safety
- **PostgreSQL**: Persistent data storage with automatic schema management
- **Redis**: Caching and real-time updates
- **JWT Authentication**: Secure, stateless authentication
- **Google OAuth 2.0**: Simple, secure user authentication
### Frontend
- React 18 with TypeScript
- Vite for fast development
- React Router for navigation
- Leaflet for mapping (planned)
- Responsive design with CSS Grid/Flexbox
- **React 18 + TypeScript**: Modern, type-safe frontend
- **Vite**: Lightning-fast development server
- **Tailwind CSS v4**: Modern utility-first styling
- **React Router**: Client-side routing
- **Responsive Design**: Mobile-friendly interface
## Getting Started
## 🚀 Quick Start
### Prerequisites
- Docker and Docker Compose
- Node.js 18+ (for local development)
- npm or yarn
- Google Cloud Console account (for OAuth setup)
### Quick Start with Docker
1. Clone the repository and navigate to the project directory:
### 1. Start the Application
```bash
git clone <repository-url>
cd vip-coordinator
```
2. Start the development environment:
```bash
make dev
```
This will start all services:
- Frontend: http://localhost:5173
- Backend API: http://localhost:3000
- PostgreSQL: localhost:5432
- Redis: localhost:6379
**Services will be available at:**
- 🌐 **Frontend**: http://localhost:5173
- 🔌 **Backend API**: http://localhost:3000
- 📚 **API Documentation**: http://localhost:3000/api-docs.html
- 🏥 **Health Check**: http://localhost:3000/api/health
### Manual Setup
### 2. Configure Google OAuth
See [SETUP_GUIDE.md](SETUP_GUIDE.md) for detailed OAuth setup instructions.
#### Backend Setup
### 3. First Login
- Visit http://localhost:5173
- Click "Continue with Google"
- First user becomes system administrator
- Subsequent users need admin approval
## 📚 API Documentation
### Interactive Documentation
Visit **http://localhost:3000/api-docs.html** for:
- 📖 Complete API reference with examples
- 🧪 "Try it out" functionality for testing endpoints
- 📋 Request/response schemas and validation rules
- 🔐 Authentication requirements for each endpoint
### Key API Categories
- **🔐 Authentication**: `/auth/*` - OAuth, user management, role assignment
- **👥 VIPs**: `/api/vips/*` - VIP profiles, scheduling, flight integration
- **🚗 Drivers**: `/api/drivers/*` - Driver management, availability, conflicts
- **✈️ Flights**: `/api/flights/*` - Flight tracking, real-time updates
- **⚙️ Admin**: `/api/admin/*` - System settings, user approval
## 🛠️ Development
### Available Commands
```bash
cd backend
npm install
npm run dev
# Start development environment
make dev
# View logs
make logs
# Stop all services
make down
# Rebuild containers
make build
# Backend development
cd backend && npm run dev
# Frontend development
cd frontend && npm run dev
```
#### Frontend Setup
```bash
cd frontend
npm install
npm run dev
```
## API Endpoints
### VIPs
- `GET /api/vips` - List all VIPs
- `POST /api/vips` - Create new VIP
- `GET /api/vips/:id` - Get VIP details
- `PUT /api/vips/:id` - Update VIP
- `DELETE /api/vips/:id` - Delete VIP
### Drivers
- `GET /api/drivers` - List all drivers
- `POST /api/drivers` - Create new driver
- `GET /api/drivers/:id` - Get driver details
- `PUT /api/drivers/:id` - Update driver
- `DELETE /api/drivers/:id` - Delete driver
### Health Check
- `GET /api/health` - Service health status
## Project Structure
### Project Structure
```
vip-coordinator/
├── backend/
├── backend/ # Node.js API server
│ ├── src/
│ │ ├── routes/ # API route handlers
│ │ ├── services/ # Business logic services
│ │ ├── config/ # Configuration and auth
│ │ └── index.ts # Main server file
│ ├── package.json
│ ├── tsconfig.json
│ └── Dockerfile
├── frontend/
├── frontend/ # React frontend
│ ├── src/
│ │ ├── components/ # Reusable components
│ │ ├── components/ # Reusable UI components
│ │ ├── pages/ # Page components
│ │ ├── types/ # TypeScript types
│ │ ├── config/ # API configuration
│ │ ├── App.tsx # Main app component
│ │ └── main.tsx # Entry point
│ ├── package.json
@@ -109,47 +144,91 @@ vip-coordinator/
├── docker-compose.dev.yml # Development environment
├── docker-compose.prod.yml # Production environment
├── Makefile # Development commands
── README.md
── SETUP_GUIDE.md # Detailed setup instructions
└── README.md # This file
```
## Development Commands
## 🔐 User Roles & Permissions
### Administrator
- Full system access
- User management and approval
- System configuration
- All VIP and driver operations
### Coordinator
- VIP management (create, edit, delete)
- Driver management
- Schedule management
- Flight tracking
### Driver
- View assigned schedules
- Update task status
- Access driver dashboard
## 🌐 Deployment
### Development
```bash
# Start development environment
make dev
```
### Production
```bash
# Build production images
make build
# Deploy to production
make deploy
# Backend only
cd backend && npm run dev
# Frontend only
cd frontend && npm run dev
# Deploy with production configuration
docker-compose -f docker-compose.prod.yml up -d
```
## Planned Features
### Environment Configuration
See [SETUP_GUIDE.md](SETUP_GUIDE.md) for detailed environment variable configuration.
## 📋 Current Status
### ✅ Implemented Features
- Google OAuth authentication with JWT
- Role-based access control
- User approval workflow
- VIP management with multi-flight support
- Driver management and scheduling
- Real-time flight tracking
- Schedule conflict detection
- Interactive API documentation
- Docker containerization
- PostgreSQL data persistence
### 🚧 Planned Features
- [ ] Real-time GPS tracking for drivers
- [ ] Flight API integration for live updates
- [ ] Push notifications for schedule changes
- [ ] Google Sheets import/export
- [ ] Mobile-responsive driver app
- [ ] Advanced scheduling with drag-and-drop
- [ ] Reporting and analytics
- [ ] Mobile driver application
- [ ] Advanced reporting and analytics
- [ ] Google Sheets integration
- [ ] Multi-tenant support
- [ ] Advanced mapping features
## Contributing
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Make your changes and test thoroughly
4. Commit your changes: `git commit -m 'Add amazing feature'`
5. Push to the branch: `git push origin feature/amazing-feature`
6. Submit a pull request
## License
## 📄 License
This project is licensed under the MIT License.
This project is licensed under the MIT License - see the LICENSE file for details.
## 🆘 Support
- 📖 **Documentation**: Check [SETUP_GUIDE.md](SETUP_GUIDE.md) for detailed setup
- 🔧 **API Reference**: Visit http://localhost:3000/api-docs.html
- 🐛 **Issues**: Report bugs and request features via GitHub issues
- 💬 **Discussions**: Use GitHub discussions for questions and ideas
---
**VIP Coordinator** - Streamlining VIP logistics with modern web technology.

314
SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,314 @@
# VIP Coordinator Setup Guide
A comprehensive guide to set up and run the VIP Coordinator system.
## 🚀 Quick Start
### Prerequisites
- Docker and Docker Compose
- Google Cloud Console account (for OAuth)
### 1. Clone and Start
```bash
git clone <repository-url>
cd vip-coordinator
make dev
```
The application will be available at:
- **Frontend**: http://localhost:5173
- **Backend API**: http://localhost:3000
- **API Documentation**: http://localhost:3000/api-docs.html
### 2. Google OAuth Setup (Required)
1. **Create Google Cloud Project**:
- Go to [Google Cloud Console](https://console.cloud.google.com/)
- Create a new project or select existing one
2. **Enable Google+ API**:
- Navigate to "APIs & Services" > "Library"
- Search for "Google+ API" and enable it
3. **Create OAuth Credentials**:
- Go to "APIs & Services" > "Credentials"
- Click "Create Credentials" > "OAuth 2.0 Client IDs"
- Application type: "Web application"
- Authorized redirect URIs: `http://localhost:3000/auth/google/callback`
4. **Configure Environment**:
```bash
# Copy the example environment file
cp backend/.env.example backend/.env
# Edit backend/.env and add your Google OAuth credentials:
GOOGLE_CLIENT_ID=your-client-id-here
GOOGLE_CLIENT_SECRET=your-client-secret-here
```
5. **Restart the Application**:
```bash
make dev
```
### 3. First Login
- Visit http://localhost:5173
- Click "Continue with Google"
- The first user to log in becomes the system administrator
- Subsequent users need administrator approval
## 🏗️ Architecture Overview
### Authentication System
- **JWT-based authentication** with Google OAuth
- **Role-based access control**: Administrator, Coordinator, Driver
- **User approval system** for new registrations
- **Simple setup** - no complex OAuth configurations needed
### Database
- **PostgreSQL** for persistent data storage
- **Automatic schema initialization** on first run
- **User management** with approval workflows
- **VIP and driver data** with scheduling
### API Structure
- **RESTful API** with comprehensive endpoints
- **OpenAPI/Swagger documentation** at `/api-docs.html`
- **Role-based endpoint protection**
- **Real-time flight tracking** integration
## 📋 Features
### Current Features
- ✅ **User Management**: Google OAuth with role-based access
- ✅ **VIP Management**: Create, edit, track VIPs with flight information
- ✅ **Driver Coordination**: Manage drivers and assignments
- ✅ **Flight Tracking**: Real-time flight status updates
- ✅ **Schedule Management**: Event scheduling with conflict detection
- ✅ **Department Support**: Office of Development and Admin departments
- ✅ **API Documentation**: Interactive Swagger UI
### User Roles
- **Administrator**: Full system access, user management
- **Coordinator**: VIP and driver management, scheduling
- **Driver**: View assigned schedules (planned)
## 🔧 Configuration
### Environment Variables
```bash
# Database
DATABASE_URL=postgresql://vip_user:vip_password@db:5432/vip_coordinator
# Authentication
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
JWT_SECRET=your-jwt-secret-key
# External APIs (Optional)
AVIATIONSTACK_API_KEY=your-aviationstack-key
# Application
FRONTEND_URL=http://localhost:5173
PORT=3000
```
### Docker Services
- **Frontend**: React + Vite development server
- **Backend**: Node.js + Express API server
- **Database**: PostgreSQL with automatic initialization
- **Redis**: Caching and real-time updates
## 🛠️ Development
### Available Commands
```bash
# Start development environment
make dev
# View logs
make logs
# Stop all services
make down
# Rebuild containers
make build
# Backend only
cd backend && npm run dev
# Frontend only
cd frontend && npm run dev
```
### API Testing
- **Interactive Documentation**: http://localhost:3000/api-docs.html
- **Health Check**: http://localhost:3000/api/health
- **Authentication Test**: Use the "Try it out" feature in Swagger UI
## 🔐 Security
### Authentication Flow
1. User clicks "Continue with Google"
2. Redirected to Google OAuth
3. Google redirects back with authorization code
4. Backend exchanges code for user info
5. JWT token generated and returned
6. Frontend stores token for API requests
### API Protection
- All API endpoints require valid JWT token
- Role-based access control on sensitive operations
- User approval system for new registrations
## 📚 API Documentation
### Key Endpoints
- **Authentication**: `/auth/*` - OAuth and user management
- **VIPs**: `/api/vips/*` - VIP management and scheduling
- **Drivers**: `/api/drivers/*` - Driver management and availability
- **Flights**: `/api/flights/*` - Flight tracking and information
- **Admin**: `/api/admin/*` - System administration
### Interactive Documentation
Visit http://localhost:3000/api-docs.html for:
- Complete API reference
- Request/response examples
- "Try it out" functionality
- Schema definitions
## 🚨 Troubleshooting
### Common Issues
**OAuth Not Working**:
- Verify Google Client ID and Secret in `.env`
- Check redirect URI in Google Console matches exactly
- Ensure Google+ API is enabled
**Database Connection Error**:
- Verify Docker containers are running: `docker ps`
- Check database logs: `docker-compose logs db`
- Restart services: `make down && make dev`
**Frontend Can't Connect to Backend**:
- Verify backend is running on port 3000
- Check CORS configuration in backend
- Ensure FRONTEND_URL is set correctly
### Getting Help
1. Check the interactive API documentation
2. Review Docker container logs
3. Verify environment configuration
4. Test with the health check endpoint
## 🔄 Production Deployment
### Prerequisites for Production
1. **Domain Setup**: Ensure your domains are configured:
- Frontend: `https://bsa.madeamess.online`
- API: `https://api.bsa.madeamess.online`
2. **SSL Certificates**: Configure SSL/TLS certificates for your domains
3. **Environment Configuration**: Copy and configure production environment:
```bash
cp .env.production .env.prod
# Edit .env.prod with your secure values
```
### Production Deployment Steps
1. **Configure Environment Variables**:
```bash
# Edit .env.prod with secure values:
# - Change DB_PASSWORD to a strong password
# - Generate new JWT_SECRET and SESSION_SECRET
# - Update ADMIN_PASSWORD
# - Set your AVIATIONSTACK_API_KEY
```
2. **Deploy with Production Configuration**:
```bash
# Load production environment
export $(cat .env.prod | xargs)
# Build and start production containers
docker-compose -f docker-compose.prod.yml up -d --build
```
3. **Verify Deployment**:
```bash
# Check container status
docker-compose -f docker-compose.prod.yml ps
# View logs
docker-compose -f docker-compose.prod.yml logs
```
### Production vs Development Differences
| Feature | Development | Production |
|---------|-------------|------------|
| Build Target | `development` | `production` |
| Source Code | Volume mounted (hot reload) | Built into image |
| Database Password | Hardcoded `changeme` | Environment variable |
| Frontend Server | Vite dev server (port 5173) | Nginx (port 80) |
| API URL | `http://localhost:3000/api` | `https://api.bsa.madeamess.online/api` |
| SSL/HTTPS | Not configured | Required |
| Restart Policy | Manual | `unless-stopped` |
### Production Environment Variables
```bash
# Database Configuration
DB_PASSWORD=your-secure-database-password-here
# Domain Configuration
DOMAIN=bsa.madeamess.online
VITE_API_URL=https://api.bsa.madeamess.online/api
# Authentication Configuration (Generate new secure keys)
JWT_SECRET=your-super-secure-jwt-secret-key-change-in-production-12345
SESSION_SECRET=your-super-secure-session-secret-change-in-production-67890
# Google OAuth Configuration
GOOGLE_CLIENT_ID=308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=https://api.bsa.madeamess.online/auth/google/callback
# Frontend URL
FRONTEND_URL=https://bsa.madeamess.online
# Flight API Configuration
AVIATIONSTACK_API_KEY=your-aviationstack-api-key
# Admin Configuration
ADMIN_PASSWORD=your-secure-admin-password
# Port Configuration
PORT=3000
```
### Production-Specific Troubleshooting
**SSL Certificate errors**: Ensure certificates are properly configured
**Domain resolution**: Verify DNS settings for your domains
**Environment variables**: Check that all required variables are set in `.env.prod`
**Firewall**: Ensure ports 80, 443, 3000 are accessible
### Production Logs
```bash
# View production container logs
docker-compose -f docker-compose.prod.yml logs backend
docker-compose -f docker-compose.prod.yml logs frontend
docker-compose -f docker-compose.prod.yml logs db
# Follow logs in real-time
docker-compose -f docker-compose.prod.yml logs -f
```
This setup guide reflects the current simple, effective architecture of the VIP Coordinator system with production-ready deployment capabilities.

View File

@@ -1,5 +1,5 @@
# Database Configuration
DATABASE_URL=postgresql://postgres:password@db:5432/vip_coordinator
DATABASE_URL=postgresql://postgres:changeme@db:5432/vip_coordinator
# Redis Configuration
REDIS_URL=redis://redis:6379
@@ -8,7 +8,7 @@ REDIS_URL=redis://redis:6379
JWT_SECRET=your-super-secure-jwt-secret-key-change-in-production-12345
SESSION_SECRET=your-super-secure-session-secret-change-in-production-67890
# Google OAuth Configuration
# Google OAuth Configuration (optional for local development)
GOOGLE_CLIENT_ID=308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=https://api.bsa.madeamess.online/auth/google/callback
@@ -21,3 +21,6 @@ AVIATIONSTACK_API_KEY=your-aviationstack-api-key
# Admin Configuration
ADMIN_PASSWORD=admin123
# Port Configuration
PORT=3000

View File

@@ -1,5 +1,5 @@
# Multi-stage build for development and production
FROM node:18-alpine AS base
FROM node:22-alpine AS base
WORKDIR /app
@@ -15,10 +15,7 @@ CMD ["npm", "run", "dev"]
# Production stage
FROM base AS production
RUN npm ci
RUN npm install
COPY . .
RUN npm run build
RUN npm prune --omit=dev
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]
CMD ["npm", "run", "dev"]

View File

@@ -13,7 +13,6 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"pg": "^8.11.3",
"redis": "^4.6.8",
"uuid": "^9.0.0"
@@ -80,7 +79,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@@ -154,6 +152,7 @@
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
@@ -163,6 +162,7 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
@@ -180,6 +180,7 @@
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz",
"integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==",
"dev": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@@ -191,6 +192,7 @@
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"dev": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
@@ -201,12 +203,14 @@
"node_modules/@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA=="
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"dev": true
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
@@ -216,19 +220,21 @@
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.17.57",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
"integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==",
"peer": true,
"dev": true,
"dependencies": {
"undici-types": "~6.19.2"
}
@@ -248,17 +254,20 @@
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true
},
"node_modules/@types/send": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dev": true,
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
@@ -268,6 +277,7 @@
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
"dev": true,
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
@@ -1016,15 +1026,6 @@
"node": ">=0.12.0"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -1064,46 +1065,6 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.20",
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jwks-rsa/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/jwks-rsa/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
@@ -1114,17 +1075,6 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1167,28 +1117,6 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -1384,7 +1312,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.0",
"pg-pool": "^3.10.0",
@@ -1990,7 +1917,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2002,7 +1928,8 @@
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
},
"node_modules/unpipe": {
"version": "1.0.0",
@@ -2119,7 +2046,6 @@
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"peer": true,
"requires": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@@ -2178,6 +2104,7 @@
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"requires": {
"@types/connect": "*",
"@types/node": "*"
@@ -2187,6 +2114,7 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"requires": {
"@types/node": "*"
}
@@ -2204,6 +2132,7 @@
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz",
"integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==",
"dev": true,
"requires": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@@ -2215,6 +2144,7 @@
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"dev": true,
"requires": {
"@types/node": "*",
"@types/qs": "*",
@@ -2225,12 +2155,14 @@
"@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA=="
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"dev": true
},
"@types/jsonwebtoken": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
"dev": true,
"requires": {
"@types/ms": "*",
"@types/node": "*"
@@ -2239,18 +2171,20 @@
"@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
},
"@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true
},
"@types/node": {
"version": "20.17.57",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
"integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==",
"peer": true,
"dev": true,
"requires": {
"undici-types": "~6.19.2"
}
@@ -2269,17 +2203,20 @@
"@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true
},
"@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true
},
"@types/send": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dev": true,
"requires": {
"@types/mime": "^1",
"@types/node": "*"
@@ -2289,6 +2226,7 @@
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
"dev": true,
"requires": {
"@types/http-errors": "*",
"@types/node": "*",
@@ -2841,11 +2779,6 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true
},
"jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="
},
"jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -2880,34 +2813,6 @@
"safe-buffer": "^5.0.1"
}
},
"jwks-rsa": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
"requires": {
"@types/express": "^4.17.20",
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"dependencies": {
"debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"requires": {
"ms": "^2.1.3"
}
},
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}
}
},
"jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
@@ -2917,16 +2822,6 @@
"safe-buffer": "^5.0.1"
}
},
"limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
},
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -2962,23 +2857,6 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"requires": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -3113,7 +2991,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
"peer": true,
"requires": {
"pg-cloudflare": "^1.2.5",
"pg-connection-string": "^2.9.0",
@@ -3509,13 +3386,13 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true
"dev": true
},
"undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
},
"unpipe": {
"version": "1.0.0",

View File

@@ -5,7 +5,7 @@
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"dev": "npx tsx src/index.ts",
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
@@ -22,7 +22,6 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"pg": "^8.11.3",
"redis": "^4.6.8",
"uuid": "^9.0.0"
@@ -34,7 +33,9 @@
"@types/node": "^20.5.0",
"@types/pg": "^8.10.2",
"@types/uuid": "^9.0.2",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.1.6"
"tsx": "^4.7.0",
"typescript": "^5.6.0"
}
}

View File

@@ -3,11 +3,8 @@ import dotenv from 'dotenv';
dotenv.config();
const useSSL = process.env.DATABASE_SSL === 'true';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:changeme@localhost:5432/vip_coordinator',
ssl: useSSL ? { rejectUnauthorized: false } : false,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,

View File

@@ -1,178 +1,134 @@
import jwt, { JwtHeader, JwtPayload } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import jwt from 'jsonwebtoken';
const auth0Domain = process.env.AUTH0_DOMAIN;
const auth0Audience = process.env.AUTH0_AUDIENCE;
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
if (!auth0Domain) {
console.warn('⚠️ AUTH0_DOMAIN is not set. Authentication routes will reject requests until configured.');
export interface User {
id: string;
google_id: string;
email: string;
name: string;
profile_picture_url?: string;
role: 'driver' | 'coordinator' | 'administrator';
created_at?: string;
last_login?: string;
is_active?: boolean;
updated_at?: string;
}
if (!auth0Audience) {
console.warn('⚠️ AUTH0_AUDIENCE is not set. Authentication routes will reject requests until configured.');
export function generateToken(user: User): string {
return jwt.sign(
{
id: user.id,
google_id: user.google_id,
email: user.email,
name: user.name,
profile_picture_url: user.profile_picture_url,
role: user.role
},
JWT_SECRET,
{ expiresIn: '24h' }
);
}
const jwks = auth0Domain
? jwksClient({
jwksUri: `https://${auth0Domain}/.well-known/jwks.json`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000
})
: null;
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const profileCache = new Map<string, { profile: Auth0UserProfile; expiresAt: number }>();
const inflightProfileRequests = new Map<string, Promise<Auth0UserProfile>>();
export interface Auth0UserProfile {
sub: string;
email?: string;
name?: string;
nickname?: string;
picture?: string;
[key: string]: unknown;
export function verifyToken(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
return {
id: decoded.id,
google_id: decoded.google_id,
email: decoded.email,
name: decoded.name,
profile_picture_url: decoded.profile_picture_url,
role: decoded.role
};
} catch (error) {
return null;
}
}
export interface VerifiedAccessToken extends JwtPayload {
sub: string;
azp?: string;
scope?: string;
// Simple Google OAuth2 client using fetch
export async function verifyGoogleToken(googleToken: string): Promise<any> {
try {
const response = await fetch(`https://www.googleapis.com/oauth2/v1/userinfo?access_token=${googleToken}`);
if (!response.ok) {
throw new Error('Invalid Google token');
}
return await response.json();
} catch (error) {
console.error('Error verifying Google token:', error);
return null;
}
}
async function getSigningKey(header: JwtHeader): Promise<string> {
if (!jwks) {
throw new Error('Auth0 JWKS client not initialised');
// Get Google OAuth2 URL
export function getGoogleAuthUrl(): string {
const clientId = process.env.GOOGLE_CLIENT_ID;
const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/auth/google/callback';
if (!clientId) {
throw new Error('GOOGLE_CLIENT_ID not configured');
}
if (!header.kid) {
throw new Error('Token signing key id (kid) is missing');
}
const signingKey = await new Promise<jwksClient.SigningKey>((resolve, reject) => {
jwks.getSigningKey(header.kid as string, (err, key) => {
if (err) {
return reject(err);
}
if (!key) {
return reject(new Error('Signing key not found'));
}
resolve(key);
});
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile',
access_type: 'offline',
prompt: 'consent'
});
const publicKey =
typeof signingKey.getPublicKey === 'function'
? signingKey.getPublicKey()
: (signingKey as any).publicKey || (signingKey as any).rsaPublicKey;
if (!publicKey) {
throw new Error('Unable to derive signing key');
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}
return publicKey;
// Exchange authorization code for tokens
export async function exchangeCodeForTokens(code: string): Promise<any> {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/auth/google/callback';
if (!clientId || !clientSecret) {
throw new Error('Google OAuth credentials not configured');
}
export async function verifyAccessToken(token: string): Promise<VerifiedAccessToken> {
if (!auth0Domain || !auth0Audience) {
throw new Error('Auth0 configuration is incomplete');
}
const decoded = jwt.decode(token, { complete: true });
if (!decoded || typeof decoded === 'string') {
throw new Error('Invalid JWT');
}
const signingKey = await getSigningKey(decoded.header);
return jwt.verify(token, signingKey, {
algorithms: ['RS256'],
audience: auth0Audience,
issuer: `https://${auth0Domain}/`
}) as VerifiedAccessToken;
}
export async function fetchAuth0UserProfile(accessToken: string, cacheKey: string, expiresAt?: number): Promise<Auth0UserProfile> {
if (!auth0Domain) {
throw new Error('Auth0 configuration is incomplete');
}
const now = Date.now();
const cached = profileCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.profile;
}
const ttl = expiresAt ? Math.max(0, expiresAt * 1000 - now) : PROFILE_CACHE_TTL_MS;
if (inflightProfileRequests.has(cacheKey)) {
return inflightProfileRequests.get(cacheKey)!;
}
const fetchPromise = (async () => {
const response = await fetch(`https://${auth0Domain}/userinfo`, {
try {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`
}
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
throw new Error(`Failed to fetch Auth0 user profile (${response.status})`);
throw new Error('Failed to exchange code for tokens');
}
const profile = (await response.json()) as Auth0UserProfile;
profileCache.set(cacheKey, { profile, expiresAt: now + ttl });
inflightProfileRequests.delete(cacheKey);
return profile;
})().catch(error => {
inflightProfileRequests.delete(cacheKey);
return await response.json();
} catch (error) {
console.error('Error exchanging code for tokens:', error);
throw error;
});
inflightProfileRequests.set(cacheKey, fetchPromise);
return fetchPromise;
}
export function clearAuth0ProfileCache(cacheKey?: string) {
if (cacheKey) {
profileCache.delete(cacheKey);
inflightProfileRequests.delete(cacheKey);
} else {
profileCache.clear();
inflightProfileRequests.clear();
}
}
export function getCachedProfile(cacheKey: string): Auth0UserProfile | undefined {
const cached = profileCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.profile;
}
return undefined;
}
export function cacheAuth0Profile(cacheKey: string, profile: Auth0UserProfile, expiresAt?: number) {
const ttl = expiresAt ? Math.max(0, expiresAt * 1000 - Date.now()) : PROFILE_CACHE_TTL_MS;
profileCache.set(cacheKey, { profile, expiresAt: Date.now() + ttl });
}
export async function fetchFreshAuth0Profile(accessToken: string): Promise<Auth0UserProfile> {
if (!auth0Domain) {
throw new Error('Auth0 configuration is incomplete');
}
const response = await fetch(`https://${auth0Domain}/userinfo`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
// Get user info from Google
export async function getGoogleUserInfo(accessToken: string): Promise<any> {
try {
const response = await fetch(`https://www.googleapis.com/oauth2/v2/userinfo?access_token=${accessToken}`);
if (!response.ok) {
throw new Error(`Failed to fetch Auth0 user profile (${response.status})`);
throw new Error('Failed to get user info');
}
return (await response.json()) as Auth0UserProfile;
return await response.json();
} catch (error) {
console.error('Error getting Google user info:', error);
throw error;
}
export function isAuth0Configured(): boolean {
return Boolean(auth0Domain && auth0Audience);
}

View File

@@ -19,6 +19,8 @@ app.use(cors({
origin: [
process.env.FRONTEND_URL || 'http://localhost:5173',
'https://bsa.madeamess.online:5173',
'https://bsa.madeamess.online',
'https://api.bsa.madeamess.online',
'http://bsa.madeamess.online:5173'
],
credentials: true
@@ -46,6 +48,9 @@ app.get('/api/health', (req: Request, res: Response) => {
// Data is now persisted using dataService - no more in-memory storage!
// Simple admin password (in production, use proper auth)
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
// Initialize flight tracking scheduler
const flightTracker = new FlightTrackingScheduler(flightService);
@@ -606,21 +611,35 @@ app.get('/api/drivers/:driverId/schedule', requireAuth, async (req: Request, res
});
// Admin routes
app.get('/api/admin/settings', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
app.post('/api/admin/authenticate', (req: Request, res: Response) => {
const { password } = req.body;
if (password === ADMIN_PASSWORD) {
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid password' });
}
});
app.get('/api/admin/settings', async (req: Request, res: Response) => {
const adminAuth = req.headers['admin-auth'];
if (adminAuth !== 'true') {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const adminSettings = await enhancedDataService.getAdminSettings();
const apiKeys = adminSettings.apiKeys || {};
// Return settings but mask API keys for display only
// IMPORTANT: Don't return the actual keys, just indicate they exist
const maskedSettings = {
apiKeys: {
aviationStackKey: apiKeys.aviationStackKey ? '***' + apiKeys.aviationStackKey.slice(-4) : '',
googleMapsKey: apiKeys.googleMapsKey ? '***' + apiKeys.googleMapsKey.slice(-4) : '',
twilioKey: apiKeys.twilioKey ? '***' + apiKeys.twilioKey.slice(-4) : '',
auth0Domain: apiKeys.auth0Domain ? '***' + apiKeys.auth0Domain.slice(-4) : '',
auth0ClientId: apiKeys.auth0ClientId ? '***' + apiKeys.auth0ClientId.slice(-4) : '',
auth0ClientSecret: apiKeys.auth0ClientSecret ? '***' + apiKeys.auth0ClientSecret.slice(-4) : ''
aviationStackKey: adminSettings.apiKeys.aviationStackKey ? '***' + adminSettings.apiKeys.aviationStackKey.slice(-4) : '',
googleMapsKey: adminSettings.apiKeys.googleMapsKey ? '***' + adminSettings.apiKeys.googleMapsKey.slice(-4) : '',
twilioKey: adminSettings.apiKeys.twilioKey ? '***' + adminSettings.apiKeys.twilioKey.slice(-4) : '',
googleClientId: adminSettings.apiKeys.googleClientId ? '***' + adminSettings.apiKeys.googleClientId.slice(-4) : '',
googleClientSecret: adminSettings.apiKeys.googleClientSecret ? '***' + adminSettings.apiKeys.googleClientSecret.slice(-4) : ''
},
systemSettings: adminSettings.systemSettings
};
@@ -631,11 +650,16 @@ app.get('/api/admin/settings', requireAuth, requireRole(['administrator']), asyn
}
});
app.post('/api/admin/settings', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
app.post('/api/admin/settings', async (req: Request, res: Response) => {
const adminAuth = req.headers['admin-auth'];
if (adminAuth !== 'true') {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const { apiKeys, systemSettings } = req.body;
const currentSettings = await enhancedDataService.getAdminSettings();
currentSettings.apiKeys = currentSettings.apiKeys || {};
// Update API keys (only if provided and not masked)
if (apiKeys) {
@@ -650,17 +674,15 @@ app.post('/api/admin/settings', requireAuth, requireRole(['administrator']), asy
if (apiKeys.twilioKey && !apiKeys.twilioKey.startsWith('***')) {
currentSettings.apiKeys.twilioKey = apiKeys.twilioKey;
}
if (apiKeys.auth0Domain && !apiKeys.auth0Domain.startsWith('***')) {
currentSettings.apiKeys.auth0Domain = apiKeys.auth0Domain;
process.env.AUTH0_DOMAIN = apiKeys.auth0Domain;
if (apiKeys.googleClientId && !apiKeys.googleClientId.startsWith('***')) {
currentSettings.apiKeys.googleClientId = apiKeys.googleClientId;
// Update the environment variable for Google OAuth
process.env.GOOGLE_CLIENT_ID = apiKeys.googleClientId;
}
if (apiKeys.auth0ClientId && !apiKeys.auth0ClientId.startsWith('***')) {
currentSettings.apiKeys.auth0ClientId = apiKeys.auth0ClientId;
process.env.AUTH0_CLIENT_ID = apiKeys.auth0ClientId;
}
if (apiKeys.auth0ClientSecret && !apiKeys.auth0ClientSecret.startsWith('***')) {
currentSettings.apiKeys.auth0ClientSecret = apiKeys.auth0ClientSecret;
process.env.AUTH0_CLIENT_SECRET = apiKeys.auth0ClientSecret;
if (apiKeys.googleClientSecret && !apiKeys.googleClientSecret.startsWith('***')) {
currentSettings.apiKeys.googleClientSecret = apiKeys.googleClientSecret;
// Update the environment variable for Google OAuth
process.env.GOOGLE_CLIENT_SECRET = apiKeys.googleClientSecret;
}
}
@@ -678,7 +700,13 @@ app.post('/api/admin/settings', requireAuth, requireRole(['administrator']), asy
}
});
app.post('/api/admin/test-api/:apiType', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
app.post('/api/admin/test-api/:apiType', async (req: Request, res: Response) => {
const adminAuth = req.headers['admin-auth'];
if (adminAuth !== 'true') {
return res.status(401).json({ error: 'Unauthorized' });
}
const { apiType } = req.params;
const { apiKey } = req.body;
@@ -727,6 +755,7 @@ async function startServer() {
// Start the server
app.listen(port, () => {
console.log(`🚀 Server is running on port ${port}`);
console.log(`🔐 Admin password: ${ADMIN_PASSWORD}`);
console.log(`📊 Admin dashboard: http://localhost:${port === 3000 ? 5173 : port}/admin`);
console.log(`🏥 Health check: http://localhost:${port}/api/health`);
console.log(`📚 API docs: http://localhost:${port}/api-docs.html`);

View File

@@ -1,126 +1,18 @@
import express, { Request, Response, NextFunction } from 'express';
import {
fetchAuth0UserProfile,
isAuth0Configured,
verifyAccessToken,
VerifiedAccessToken,
Auth0UserProfile,
getCachedProfile,
cacheAuth0Profile
generateToken,
verifyToken,
getGoogleAuthUrl,
exchangeCodeForTokens,
getGoogleUserInfo,
User
} from '../config/simpleAuth';
import databaseService from '../services/databaseService';
type AuthedRequest = Request & {
auth?: {
token: string;
claims: VerifiedAccessToken;
profile?: Auth0UserProfile | null;
};
user?: any;
};
const router = express.Router();
function mapUserForResponse(user: any) {
return {
id: user.id,
email: user.email,
name: user.name,
picture: user.profile_picture_url,
role: user.role,
approval_status: user.approval_status,
created_at: user.created_at,
last_login: user.last_login,
provider: 'auth0'
};
}
async function syncUserWithDatabase(claims: VerifiedAccessToken, token: string): Promise<{ user: any; profile: Auth0UserProfile | null }> {
const auth0Id = claims.sub;
const initialAdminEmails = (process.env.INITIAL_ADMIN_EMAILS || '')
.split(',')
.map(email => email.trim().toLowerCase())
.filter(Boolean);
let profile: Auth0UserProfile | null = null;
let user = await databaseService.getUserById(auth0Id);
if (user) {
const updated = await databaseService.updateUserLastSignIn(user.email);
user = updated || user;
const isSeedAdmin = initialAdminEmails.includes((user.email || '').toLowerCase());
if (isSeedAdmin && user.role !== 'administrator') {
user = await databaseService.updateUserRole(user.email, 'administrator');
}
if (isSeedAdmin && user.approval_status !== 'approved') {
user = await databaseService.updateUserApprovalStatus(user.email, 'approved');
}
return { user, profile };
}
const cacheKey = auth0Id;
profile = getCachedProfile(cacheKey) || null;
if (!profile) {
profile = await fetchAuth0UserProfile(token, cacheKey, claims.exp);
cacheAuth0Profile(cacheKey, profile, claims.exp);
}
if (!profile.email) {
throw new Error('Auth0 profile did not include an email address');
}
const existingByEmail = await databaseService.getUserByEmail(profile.email);
if (existingByEmail && existingByEmail.id !== auth0Id) {
await databaseService.migrateUserId(existingByEmail.id, auth0Id);
user = await databaseService.getUserById(auth0Id);
} else if (existingByEmail) {
user = existingByEmail;
}
const displayName = profile.name || profile.nickname || profile.email;
const picture = typeof profile.picture === 'string' ? profile.picture : undefined;
const isSeedAdmin = initialAdminEmails.includes(profile.email.toLowerCase());
if (!user) {
const approvedUserCount = await databaseService.getApprovedUserCount();
const role = isSeedAdmin
? 'administrator'
: approvedUserCount === 0
? 'administrator'
: 'coordinator';
user = await databaseService.createUser({
id: auth0Id,
google_id: auth0Id,
email: profile.email,
name: displayName,
profile_picture_url: picture,
role
});
if (role === 'administrator') {
user = await databaseService.updateUserApprovalStatus(profile.email, 'approved');
}
} else {
const updated = await databaseService.updateUserLastSignIn(user.email);
user = updated || user;
if (isSeedAdmin && user.role !== 'administrator') {
user = await databaseService.updateUserRole(user.email, 'administrator');
}
if (isSeedAdmin && user.approval_status !== 'approved') {
user = await databaseService.updateUserApprovalStatus(user.email, 'approved');
}
}
return { user, profile };
}
export async function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
// Middleware to check authentication
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
@@ -128,32 +20,20 @@ export async function requireAuth(req: AuthedRequest, res: Response, next: NextF
}
const token = authHeader.substring(7);
const user = verifyToken(token);
try {
const claims = await verifyAccessToken(token);
const { user, profile } = await syncUserWithDatabase(claims, token);
req.auth = { token, claims, profile };
req.user = user;
if (user.approval_status !== 'approved') {
return res.status(403).json({
error: 'pending_approval',
message: 'Your account is pending administrator approval.',
user: mapUserForResponse(user)
});
if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}
return next();
} catch (error: any) {
console.error('Auth0 token verification failed:', error);
return res.status(401).json({ error: 'Invalid or expired token' });
}
(req as any).user = user;
next();
}
// Middleware to check role
export function requireRole(roles: string[]) {
return (req: AuthedRequest, res: Response, next: NextFunction) => {
const user = req.user;
return (req: Request, res: Response, next: NextFunction) => {
const user = (req as any).user;
if (!user || !roles.includes(user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
@@ -163,14 +43,22 @@ export function requireRole(roles: string[]) {
};
}
router.get('/setup', async (_req: Request, res: Response) => {
// Get current user
router.get('/me', requireAuth, (req: Request, res: Response) => {
res.json((req as any).user);
});
// Setup status endpoint (required by frontend)
router.get('/setup', async (req: Request, res: Response) => {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
try {
const userCount = await databaseService.getUserCount();
res.json({
setupCompleted: isAuth0Configured(),
setupCompleted: !!(clientId && clientSecret && clientId !== 'your-google-client-id-from-console'),
firstAdminCreated: userCount > 0,
oauthConfigured: isAuth0Configured(),
authProvider: 'auth0'
oauthConfigured: !!(clientId && clientSecret)
});
} catch (error) {
console.error('Error checking setup status:', error);
@@ -178,35 +66,206 @@ router.get('/setup', async (_req: Request, res: Response) => {
}
});
router.get('/me', requireAuth, async (req: AuthedRequest, res: Response) => {
res.json({
user: mapUserForResponse(req.user),
auth0: {
sub: req.auth?.claims.sub,
scope: req.auth?.claims.scope
// Start Google OAuth flow
router.get('/google', (req: Request, res: Response) => {
try {
const authUrl = getGoogleAuthUrl();
res.redirect(authUrl);
} catch (error) {
console.error('Error starting Google OAuth:', error);
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
res.redirect(`${frontendUrl}?error=oauth_not_configured`);
}
});
// Handle Google OAuth callback (this is where Google redirects back to)
router.get('/google/callback', async (req: Request, res: Response) => {
const { code, error } = req.query;
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
if (error) {
console.error('OAuth error:', error);
return res.redirect(`${frontendUrl}?error=${error}`);
}
if (!code) {
return res.redirect(`${frontendUrl}?error=no_code`);
}
try {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code as string);
// Get user info
const googleUser = await getGoogleUserInfo(tokens.access_token);
// Check if user exists or create new user
let user = await databaseService.getUserByEmail(googleUser.email);
if (!user) {
// Determine role - first user becomes admin, others need approval
const approvedUserCount = await databaseService.getApprovedUserCount();
const role = approvedUserCount === 0 ? 'administrator' : 'coordinator';
user = await databaseService.createUser({
id: googleUser.id,
google_id: googleUser.id,
email: googleUser.email,
name: googleUser.name,
profile_picture_url: googleUser.picture,
role
});
router.post('/logout', (_req: Request, res: Response) => {
// Auto-approve first admin, others need approval
if (approvedUserCount === 0) {
await databaseService.updateUserApprovalStatus(googleUser.email, 'approved');
user.approval_status = 'approved';
}
} else {
// Update last sign in
await databaseService.updateUserLastSignIn(googleUser.email);
console.log(`✅ User logged in: ${user.name} (${user.email})`);
}
// Check if user is approved
if (user.approval_status !== 'approved') {
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
return res.redirect(`${frontendUrl}?error=pending_approval&message=Your account is pending administrator approval`);
}
// Generate JWT token
const token = generateToken(user);
// Redirect to frontend with token
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
} catch (error) {
console.error('Error in OAuth callback:', error);
res.redirect(`${frontendUrl}?error=oauth_failed`);
}
});
// Exchange OAuth code for JWT token (alternative endpoint for frontend)
router.post('/google/exchange', async (req: Request, res: Response) => {
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Authorization code is required' });
}
try {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code);
// Get user info
const googleUser = await getGoogleUserInfo(tokens.access_token);
// Check if user exists or create new user
let user = await databaseService.getUserByEmail(googleUser.email);
if (!user) {
// Determine role - first user becomes admin
const userCount = await databaseService.getUserCount();
const role = userCount === 0 ? 'administrator' : 'coordinator';
user = await databaseService.createUser({
id: googleUser.id,
google_id: googleUser.id,
email: googleUser.email,
name: googleUser.name,
profile_picture_url: googleUser.picture,
role
});
} else {
// Update last sign in
await databaseService.updateUserLastSignIn(googleUser.email);
console.log(`✅ User logged in: ${user.name} (${user.email})`);
}
// Generate JWT token
const token = generateToken(user);
// Return token to frontend
res.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
picture: user.profile_picture_url,
role: user.role
}
});
} catch (error) {
console.error('Error in OAuth exchange:', error);
res.status(500).json({ error: 'Failed to exchange authorization code' });
}
});
// Get OAuth URL for frontend to redirect to
router.get('/google/url', (req: Request, res: Response) => {
try {
const authUrl = getGoogleAuthUrl();
res.json({ url: authUrl });
} catch (error) {
console.error('Error getting Google OAuth URL:', error);
res.status(500).json({ error: 'OAuth not configured' });
}
});
// Logout
router.post('/logout', (req: Request, res: Response) => {
// With JWT, logout is handled client-side by removing the token
res.json({ message: 'Logged out successfully' });
});
router.get('/status', requireAuth, (req: AuthedRequest, res: Response) => {
// Get auth status
router.get('/status', (req: Request, res: Response) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.json({ authenticated: false });
}
const token = authHeader.substring(7);
const user = verifyToken(token);
if (!user) {
return res.json({ authenticated: false });
}
res.json({
authenticated: true,
user: mapUserForResponse(req.user)
user: {
id: user.id,
email: user.email,
name: user.name,
picture: user.profile_picture_url,
role: user.role
}
});
});
// USER MANAGEMENT ENDPOINTS
// List all users (admin only)
router.get('/users', requireAuth, requireRole(['administrator']), async (_req: Request, res: Response) => {
router.get('/users', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
try {
const users = await databaseService.getAllUsers();
res.json(users.map(mapUserForResponse));
const userList = users.map(user => ({
id: user.id,
email: user.email,
name: user.name,
picture: user.profile_picture_url,
role: user.role,
created_at: user.created_at,
last_login: user.last_login,
provider: 'google'
}));
res.json(userList);
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({ error: 'Failed to fetch users' });
@@ -230,7 +289,12 @@ router.patch('/users/:email/role', requireAuth, requireRole(['administrator']),
res.json({
success: true,
user: mapUserForResponse(user)
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
});
} catch (error) {
console.error('Error updating user role:', error);
@@ -239,9 +303,9 @@ router.patch('/users/:email/role', requireAuth, requireRole(['administrator']),
});
// Delete user (admin only)
router.delete('/users/:email', requireAuth, requireRole(['administrator']), async (req: AuthedRequest, res: Response) => {
router.delete('/users/:email', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
const { email } = req.params;
const currentUser = req.user;
const currentUser = (req as any).user;
// Prevent admin from deleting themselves
if (email === currentUser.email) {
@@ -272,7 +336,17 @@ router.get('/users/:email', requireAuth, requireRole(['administrator']), async (
return res.status(404).json({ error: 'User not found' });
}
res.json(mapUserForResponse(user));
res.json({
id: user.id,
email: user.email,
name: user.name,
picture: user.profile_picture_url,
role: user.role,
created_at: user.created_at,
last_login: user.last_login,
provider: 'google',
approval_status: user.approval_status
});
} catch (error) {
console.error('Error fetching user:', error);
res.status(500).json({ error: 'Failed to fetch user' });
@@ -286,7 +360,16 @@ router.get('/users/pending/list', requireAuth, requireRole(['administrator']), a
try {
const pendingUsers = await databaseService.getPendingUsers();
const userList = pendingUsers.map(mapUserForResponse);
const userList = pendingUsers.map(user => ({
id: user.id,
email: user.email,
name: user.name,
picture: user.profile_picture_url,
role: user.role,
created_at: user.created_at,
provider: 'google',
approval_status: user.approval_status
}));
res.json(userList);
} catch (error) {
@@ -313,7 +396,13 @@ router.patch('/users/:email/approval', requireAuth, requireRole(['administrator'
res.json({
success: true,
message: `User ${status} successfully`,
user: mapUserForResponse(user)
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
approval_status: user.approval_status
}
});
} catch (error) {
console.error('Error updating user approval:', error);

View File

@@ -6,10 +6,9 @@ class DatabaseService {
private redis: RedisClientType;
constructor() {
const useSSL = process.env.DATABASE_SSL === 'true';
this.pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: useSSL ? { rejectUnauthorized: false } : false
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
// Initialize Redis connection
@@ -98,38 +97,6 @@ class DatabaseService {
ADD COLUMN IF NOT EXISTS approval_status VARCHAR(20) DEFAULT 'pending' CHECK (approval_status IN ('pending', 'approved', 'denied'))
`);
// Admin settings storage table
await this.query(`
CREATE TABLE IF NOT EXISTS admin_settings (
id SERIAL PRIMARY KEY,
setting_key VARCHAR(255) UNIQUE NOT NULL,
setting_value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await this.query(`
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
`);
await this.query(`
DROP TRIGGER IF EXISTS update_admin_settings_updated_at ON admin_settings
`);
await this.query(`
CREATE TRIGGER update_admin_settings_updated_at
BEFORE UPDATE ON admin_settings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column()
`);
// Create indexes
await this.query(`
CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id)
@@ -191,31 +158,6 @@ class DatabaseService {
return result.rows[0] || null;
}
async migrateUserId(oldId: string, newId: string): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(
'UPDATE drivers SET user_id = $2 WHERE user_id = $1',
[oldId, newId]
);
await client.query(
'UPDATE users SET id = $2, google_id = $2 WHERE id = $1',
[oldId, newId]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async getAllUsers(): Promise<any[]> {
const query = 'SELECT * FROM users ORDER BY created_at ASC';
const result = await this.query(query);
@@ -313,29 +255,52 @@ class DatabaseService {
}
}
// VIP schema (flights, drivers, schedules)
// VIP table initialization using the correct schema
async initializeVipTables(): Promise<void> {
try {
// Check if VIPs table exists and has the correct schema
const tableExists = await this.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'vips'
)
`);
if (tableExists.rows[0].exists) {
// Check if the table has the correct columns
const columnCheck = await this.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'vips'
AND column_name = 'organization'
`);
if (columnCheck.rows.length === 0) {
console.log('🔄 Migrating VIPs table to new schema...');
// Drop the old table and recreate with correct schema
await this.query(`DROP TABLE IF EXISTS vips CASCADE`);
}
}
// Create VIPs table with correct schema matching enhancedDataService expectations
await this.query(`
CREATE TABLE IF NOT EXISTS vips (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
organization VARCHAR(255) NOT NULL,
department VARCHAR(255) DEFAULT 'Office of Development',
transport_mode VARCHAR(50) NOT NULL CHECK (transport_mode IN ('flight', 'self-driving')),
expected_arrival TIMESTAMP,
needs_airport_pickup BOOLEAN DEFAULT false,
needs_venue_transport BOOLEAN DEFAULT true,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await this.query(`
ALTER TABLE vips
ADD COLUMN IF NOT EXISTS organization VARCHAR(255),
ADD COLUMN IF NOT EXISTS department VARCHAR(255) DEFAULT 'Office of Development',
ADD COLUMN IF NOT EXISTS transport_mode VARCHAR(50),
ADD COLUMN IF NOT EXISTS expected_arrival TIMESTAMP,
ADD COLUMN IF NOT EXISTS needs_airport_pickup BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS needs_venue_transport BOOLEAN DEFAULT true
`);
// Create flights table (for VIPs with flight transport)
await this.query(`
CREATE TABLE IF NOT EXISTS flights (
id SERIAL PRIMARY KEY,
@@ -355,22 +320,69 @@ class DatabaseService {
)
`);
// Check and migrate drivers table
const driversTableExists = await this.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'drivers'
)
`);
if (driversTableExists.rows[0].exists) {
// Check if drivers table has the correct schema (phone column and department column)
const driversSchemaCheck = await this.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'drivers'
AND column_name IN ('phone', 'department')
`);
if (driversSchemaCheck.rows.length < 2) {
console.log('🔄 Migrating drivers table to new schema...');
await this.query(`DROP TABLE IF EXISTS drivers CASCADE`);
}
}
// Create drivers table with correct schema
await this.query(`
CREATE TABLE IF NOT EXISTS drivers (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
phone VARCHAR(50) NOT NULL,
department VARCHAR(255) DEFAULT 'Office of Development',
user_id VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await this.query(`
ALTER TABLE drivers
ADD COLUMN IF NOT EXISTS phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS department VARCHAR(255) DEFAULT 'Office of Development',
ADD COLUMN IF NOT EXISTS user_id VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL
// Check and migrate schedule_events table
const scheduleTableExists = await this.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'schedule_events'
)
`);
if (!scheduleTableExists.rows[0].exists) {
// Check for old 'schedules' table and drop it
const oldScheduleExists = await this.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'schedules'
)
`);
if (oldScheduleExists.rows[0].exists) {
console.log('🔄 Migrating schedules table to schedule_events...');
await this.query(`DROP TABLE IF EXISTS schedules CASCADE`);
}
}
// Create schedule_events table
await this.query(`
CREATE TABLE IF NOT EXISTS schedule_events (
id VARCHAR(255) PRIMARY KEY,
@@ -388,42 +400,66 @@ class DatabaseService {
)
`);
// Create system_setup table for tracking initial setup
await this.query(`
DROP TABLE IF EXISTS schedules
CREATE TABLE IF NOT EXISTS system_setup (
id SERIAL PRIMARY KEY,
setup_completed BOOLEAN DEFAULT false,
first_admin_created BOOLEAN DEFAULT false,
setup_date TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create admin_settings table
await this.query(`
ALTER TABLE schedule_events
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS assigned_driver_id VARCHAR(255) REFERENCES drivers(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'scheduled',
ADD COLUMN IF NOT EXISTS event_type VARCHAR(50),
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
CREATE TABLE IF NOT EXISTS admin_settings (
id SERIAL PRIMARY KEY,
setting_key VARCHAR(255) UNIQUE NOT NULL,
setting_value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create indexes for better performance
await this.query(`CREATE INDEX IF NOT EXISTS idx_vips_transport_mode ON vips(transport_mode)`);
await this.query(`CREATE INDEX IF NOT EXISTS idx_flights_vip_id ON flights(vip_id)`);
await this.query(`CREATE INDEX IF NOT EXISTS idx_flights_date ON flights(flight_date)`);
await this.query(`CREATE INDEX IF NOT EXISTS idx_schedule_events_vip_id ON schedule_events(vip_id)`);
await this.query(`CREATE INDEX IF NOT EXISTS idx_schedule_events_driver_id ON schedule_events(assigned_driver_id)`);
await this.query(`CREATE INDEX IF NOT EXISTS idx_schedule_events_start_time ON schedule_events(start_time)`);
await this.query(`CREATE INDEX IF NOT EXISTS idx_schedule_events_status ON schedule_events(status)`);
await this.query(`CREATE INDEX IF NOT EXISTS idx_drivers_user_id ON drivers(user_id)`);
// Create updated_at trigger function
await this.query(`
CREATE INDEX IF NOT EXISTS idx_vips_transport_mode ON vips(transport_mode)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_flights_vip_id ON flights(vip_id)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_flights_date ON flights(flight_date)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_schedule_events_vip_id ON schedule_events(vip_id)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_schedule_events_driver_id ON schedule_events(assigned_driver_id)
`);
await this.query(`
CREATE INDEX IF NOT EXISTS idx_schedule_events_start_time ON schedule_events(start_time)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql'
`);
console.log('✅ VIP and schedule tables initialized successfully');
// Create triggers for updated_at (drop if exists first)
await this.query(`DROP TRIGGER IF EXISTS update_vips_updated_at ON vips`);
await this.query(`DROP TRIGGER IF EXISTS update_flights_updated_at ON flights`);
await this.query(`DROP TRIGGER IF EXISTS update_drivers_updated_at ON drivers`);
await this.query(`DROP TRIGGER IF EXISTS update_schedule_events_updated_at ON schedule_events`);
await this.query(`DROP TRIGGER IF EXISTS update_admin_settings_updated_at ON admin_settings`);
await this.query(`CREATE TRIGGER update_vips_updated_at BEFORE UPDATE ON vips FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
await this.query(`CREATE TRIGGER update_flights_updated_at BEFORE UPDATE ON flights FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
await this.query(`CREATE TRIGGER update_drivers_updated_at BEFORE UPDATE ON drivers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
await this.query(`CREATE TRIGGER update_schedule_events_updated_at BEFORE UPDATE ON schedule_events FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
await this.query(`CREATE TRIGGER update_admin_settings_updated_at BEFORE UPDATE ON admin_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
console.log('✅ VIP Coordinator database schema initialized successfully');
} catch (error) {
console.error('❌ Failed to initialize VIP tables:', error);
throw error;
}
}

View File

@@ -589,13 +589,11 @@ class EnhancedDataService {
// Default settings structure
const defaultSettings = {
apiKeys: {
aviationStackKey: process.env.AVIATIONSTACK_API_KEY || '',
aviationStackKey: '',
googleMapsKey: '',
twilioKey: '',
auth0Domain: process.env.AUTH0_DOMAIN || '',
auth0ClientId: process.env.AUTH0_CLIENT_ID || '',
auth0ClientSecret: process.env.AUTH0_CLIENT_SECRET || '',
auth0Audience: process.env.AUTH0_AUDIENCE || ''
googleClientId: '',
googleClientSecret: ''
},
systemSettings: {
defaultPickupLocation: '',

View File

@@ -118,7 +118,7 @@ class FlightService {
console.log('Note: Free tier returns recent flights only, not future scheduled flights');
const response = await fetch(url);
const data: any = await response.json();
const data = await response.json();
console.log('AviationStack response status:', response.status);
@@ -128,12 +128,12 @@ class FlightService {
}
// Check for API errors in response
if (data?.error) {
if (data.error) {
console.error('AviationStack API error:', data.error);
return null;
}
if (Array.isArray(data?.data) && data.data.length > 0) {
if (data.data && data.data.length > 0) {
// This is a valid flight number that exists!
console.log(`✅ Valid flight number: ${formattedFlightNumber} exists in the system`);

View File

@@ -37,6 +37,8 @@ services:
build:
context: ./frontend
target: development
environment:
VITE_API_URL: http://localhost:3000/api
ports:
- 5173:5173
depends_on:

View File

@@ -6,55 +6,53 @@ services:
image: postgres:15
environment:
POSTGRES_DB: vip_coordinator
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- 5432:5432
restart: unless-stopped
redis:
image: redis:7
ports:
- 6379:6379
restart: unless-stopped
backend:
build:
context: ./backend
target: production
environment:
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@db:5432/vip_coordinator
DATABASE_URL: postgresql://postgres:${DB_PASSWORD:-changeme}@db:5432/vip_coordinator
REDIS_URL: redis://redis:6379
NODE_ENV: production
FRONTEND_URL: ${FRONTEND_URL}
AUTH0_DOMAIN: ${AUTH0_DOMAIN}
AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID}
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET}
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE}
INITIAL_ADMIN_EMAILS: ${INITIAL_ADMIN_EMAILS:-}
AVIATIONSTACK_API_KEY: ${AVIATIONSTACK_API_KEY:-}
DATABASE_SSL: ${DATABASE_SSL:-false}
PGSSLMODE: disable
JWT_SECRET: ${JWT_SECRET:-your-super-secure-jwt-secret-key-change-in-production-12345}
SESSION_SECRET: ${SESSION_SECRET:-your-super-secure-session-secret-change-in-production-67890}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-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}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-https://api.bsa.madeamess.online/auth/google/callback}
FRONTEND_URL: ${FRONTEND_URL:-https://bsa.madeamess.online}
AVIATIONSTACK_API_KEY: ${AVIATIONSTACK_API_KEY:-your-aviationstack-api-key}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
PORT: ${PORT:-3000}
ports:
- 3000:3000
depends_on:
- db
- redis
restart: unless-stopped
frontend:
build:
context: ./frontend
target: serve
args:
VITE_AUTH0_DOMAIN: ${AUTH0_DOMAIN}
VITE_AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID}
VITE_AUTH0_AUDIENCE: ${AUTH0_AUDIENCE}
target: production
environment:
VITE_API_URL: ${VITE_API_URL:-https://api.bsa.madeamess.online/api}
ports:
- 80:80
- 443:443
volumes:
- /opt/vip-coordinator/certs:/etc/nginx/certs:ro
- 5173:5173
depends_on:
- backend
restart: unless-stopped
volumes:
postgres-data:

View File

@@ -1,5 +1,5 @@
# Multi-stage build for development and production
FROM node:18-alpine AS base
FROM node:22-alpine AS base
WORKDIR /app
@@ -15,20 +15,7 @@ CMD ["npm", "run", "dev"]
# Production stage
FROM base AS production
ARG VITE_AUTH0_DOMAIN
ARG VITE_AUTH0_CLIENT_ID
ARG VITE_AUTH0_AUDIENCE
ENV VITE_AUTH0_DOMAIN=${VITE_AUTH0_DOMAIN}
ENV VITE_AUTH0_CLIENT_ID=${VITE_AUTH0_CLIENT_ID}
ENV VITE_AUTH0_AUDIENCE=${VITE_AUTH0_AUDIENCE}
RUN npm ci
RUN npm install
COPY . .
RUN npm run build
RUN npm prune --omit=dev
# Serve with nginx
FROM nginx:alpine AS serve
COPY --from=production /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -1,5 +1,3 @@
worker_processes auto;
events {
worker_connections 1024;
}
@@ -9,56 +7,45 @@ http {
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
server {
listen 80;
server_name bsa.madeamess.online _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name bsa.madeamess.online _;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_prefer_server_ciphers on;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss image/svg+xml;
gzip_min_length 256;
location /auth/callback {
try_files $uri /index.html;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# API proxy to backend
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location /auth/ {
proxy_pass http://backend:3000/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
location / {
try_files $uri $uri/ /index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

View File

@@ -3,34 +3,38 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "node ./node_modules/vite/bin/vite.js",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@auth0/auth0-react": "^2.8.0",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.15.0",
"tailwindcss": "^3.4.14",
"vite": "^4.5.14"
"react-router-dom": "^6.15.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.4",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.21",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.47",
"typescript": "^5.0.2"
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.14",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"@tailwindcss/postcss": "^4.1.8",
"postcss": "^8.5.4",
"tailwindcss": "^4.1.8",
"typescript": "^5.6.0",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
}
}

View File

@@ -1,6 +1,7 @@
/* Modern App-specific styles using Tailwind utilities */
/* Enhanced button styles */
@layer components {
.btn-modern {
@apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
}
@@ -20,8 +21,10 @@
.btn-gradient-amber {
@apply bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white;
}
}
/* Status badges */
@layer components {
.status-badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold;
}
@@ -41,8 +44,10 @@
.status-cancelled {
@apply bg-red-100 text-red-800 border border-red-200;
}
}
/* Card enhancements */
@layer components {
.card-modern {
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
}
@@ -54,8 +59,10 @@
.card-content {
@apply p-6;
}
}
/* Loading states */
@layer components {
.loading-spinner {
@apply animate-spin rounded-full border-4 border-blue-600 border-t-transparent;
}
@@ -67,8 +74,10 @@
.skeleton {
@apply animate-pulse bg-slate-200 rounded;
}
}
/* Form enhancements */
@layer components {
.form-modern {
@apply space-y-6;
}
@@ -88,8 +97,10 @@
.form-select-modern {
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white transition-all duration-200;
}
}
/* Animation utilities */
@layer utilities {
.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
@@ -101,6 +112,7 @@
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
}
@keyframes fadeIn {
from {
@@ -149,6 +161,7 @@
}
/* Glass morphism effect */
@layer utilities {
.glass {
@apply bg-white/80 backdrop-blur-lg border border-white/20;
}
@@ -156,8 +169,10 @@
.glass-dark {
@apply bg-slate-900/80 backdrop-blur-lg border border-slate-700/20;
}
}
/* Hover effects */
@layer utilities {
.hover-lift {
@apply transition-transform duration-200 hover:-translate-y-1;
}
@@ -169,3 +184,4 @@
.hover-scale {
@apply transition-transform duration-200 hover:scale-105;
}
}

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { apiCall } from './config/api';
import VipList from './pages/VipList';
import VipDetails from './pages/VipDetails';
@@ -12,102 +11,54 @@ import UserManagement from './components/UserManagement';
import Login from './components/Login';
import './App.css';
const AUTH0_AUDIENCE = import.meta.env.VITE_AUTH0_AUDIENCE;
function App() {
const {
isLoading: authLoading,
isAuthenticated,
loginWithRedirect,
logout,
getAccessTokenSilently,
user: auth0User,
error: authError
} = useAuth0();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [pendingApproval, setPendingApproval] = useState(false);
useEffect(() => {
const bootstrap = async () => {
if (!isAuthenticated) {
setUser(null);
setStatusMessage(null);
setPendingApproval(false);
setLoading(false);
return;
}
setLoading(true);
setPendingApproval(false);
setStatusMessage(null);
try {
const token = await getAccessTokenSilently({
authorizationParams: {
...(AUTH0_AUDIENCE ? { audience: AUTH0_AUDIENCE } : {}),
scope: 'openid profile email'
}
});
localStorage.setItem('authToken', token);
const response = await apiCall('/auth/me', {
// Check if user is already authenticated
const token = localStorage.getItem('authToken');
if (token) {
apiCall('/auth/me', {
headers: {
Authorization: `Bearer ${token}`
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (res.ok) {
return res.json();
} else {
// Token is invalid, remove it
localStorage.removeItem('authToken');
throw new Error('Invalid token');
}
})
.then(userData => {
setUser(userData);
setLoading(false);
})
.catch(error => {
console.error('Auth check failed:', error);
setLoading(false);
});
if (response.status === 403) {
const data = await response.json();
setUser(null);
setPendingApproval(true);
setStatusMessage(data.message || 'Your account is pending administrator approval.');
return;
}
if (!response.ok) {
throw new Error(`Failed to load profile (${response.status})`);
}
const data = await response.json();
const userRecord = data.user || data;
const resolvedName =
userRecord.name ||
auth0User?.name ||
auth0User?.nickname ||
auth0User?.email ||
userRecord.email;
setUser({
...userRecord,
name: resolvedName,
role: userRecord.role,
picture: userRecord.picture || auth0User?.picture
});
} catch (error) {
console.error('Authentication bootstrap failed:', error);
setUser(null);
setStatusMessage('Authentication failed. Please try signing in again.');
} finally {
} else {
setLoading(false);
}
};
}, []);
if (!authLoading) {
bootstrap();
}
}, [isAuthenticated, authLoading, getAccessTokenSilently, auth0User]);
const handleLogin = (userData: any) => {
setUser(userData);
};
const handleLogout = () => {
localStorage.removeItem('authToken');
logout({ logoutParams: { returnTo: window.location.origin } });
setUser(null);
// Optionally call logout endpoint
apiCall('/auth/logout', { method: 'POST' })
.catch(error => console.error('Logout error:', error));
};
if (authLoading || loading) {
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
<div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4">
@@ -118,68 +69,23 @@ function App() {
);
}
if (pendingApproval) {
return (
<div className="min-h-screen bg-gradient-to-br from-amber-50 to-rose-50 flex justify-center items-center px-4">
<div className="bg-white border border-amber-200/60 rounded-2xl shadow-xl max-w-xl w-full p-8 space-y-4 text-center">
<div className="flex justify-center">
<div className="w-16 h-16 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-3xl">
</div>
</div>
<h1 className="text-2xl font-bold text-slate-800">Awaiting Administrator Approval</h1>
<p className="text-slate-600">
{statusMessage ||
'Thanks for signing in. An administrator needs to approve your account before you can access the dashboard.'}
</p>
<button
onClick={handleLogout}
className="btn btn-secondary mt-4"
>
Sign out
</button>
</div>
</div>
);
// Handle OAuth callback route even when not logged in
if (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback') {
return <Login onLogin={handleLogin} />;
}
const beginLogin = async () => {
try {
await loginWithRedirect({
authorizationParams: {
...(AUTH0_AUDIENCE ? { audience: AUTH0_AUDIENCE } : {}),
scope: 'openid profile email',
redirect_uri: `${window.location.origin}/auth/callback`
if (!user) {
return <Login onLogin={handleLogin} />;
}
});
} catch (error: any) {
console.error('Auth0 login failed:', error);
setStatusMessage(error?.message || 'Authentication failed. Please try again.');
}
};
if (!isAuthenticated || !user) {
return (
<Login
onLogin={beginLogin}
errorMessage={statusMessage || authError?.message}
/>
);
}
const displayName =
(user.name && user.name.trim().length > 0)
? user.name
: (user.email || 'User');
const displayInitial = displayName.trim().charAt(0).toUpperCase();
const userRole = user.role || 'user';
return (
<Router>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
{/* Modern Navigation */}
<nav className="bg-white/80 backdrop-blur-lg border-b border-slate-200/60 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo/Brand */}
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">VC</span>
@@ -189,6 +95,7 @@ function App() {
</h1>
</div>
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-1">
<Link
to="/"
@@ -208,7 +115,7 @@ function App() {
>
Drivers
</Link>
{userRole === 'administrator' && (
{(user.role === 'administrator' || user.role === 'coordinator') && (
<Link
to="/admin"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all duration-200"
@@ -216,7 +123,7 @@ function App() {
Admin
</Link>
)}
{userRole === 'administrator' && (
{user.role === 'administrator' && (
<Link
to="/users"
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-all duration-200"
@@ -226,20 +133,17 @@ function App() {
)}
</div>
{/* User Menu */}
<div className="flex items-center space-x-4">
<div className="hidden sm:flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center overflow-hidden">
{user.picture ? (
<img src={user.picture} alt={displayName} className="w-8 h-8 object-cover" />
) : (
<div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-medium">
{displayInitial}
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="text-sm">
<div className="font-medium text-slate-900">{displayName}</div>
<div className="text-slate-500 capitalize">{userRole}</div>
<div className="font-medium text-slate-900">{user.name}</div>
<div className="text-slate-500 capitalize">{user.role}</div>
</div>
</div>
<button
@@ -253,6 +157,7 @@ function App() {
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<Dashboard />} />

View File

@@ -94,101 +94,6 @@
margin: 0;
}
.dev-login-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 24px 0;
color: #94a3b8;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dev-login-divider::before,
.dev-login-divider::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
.dev-login-form {
display: flex;
flex-direction: column;
gap: 16px;
text-align: left;
}
.dev-login-form h3 {
margin: 0;
color: #1e293b;
font-size: 18px;
font-weight: 600;
}
.dev-login-hint {
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.4;
}
.dev-login-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
color: #334155;
}
.dev-login-form input {
width: 100%;
padding: 10px 12px;
border: 1px solid #cbd5f5;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.dev-login-form input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.dev-login-error {
background: #fee2e2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
}
.dev-login-button {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.dev-login-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.2);
}
.dev-login-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-footer {
border-top: 1px solid #eee;
padding-top: 20px;

View File

@@ -3,15 +3,15 @@ import { apiCall } from '../config/api';
import './Login.css';
interface LoginProps {
onLogin: () => void;
errorMessage?: string | null | undefined;
onLogin: (user: any) => void;
}
const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
const Login: React.FC<LoginProps> = ({ onLogin }) => {
const [setupStatus, setSetupStatus] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check system setup status
apiCall('/auth/setup')
.then(res => res.json())
.then(data => {
@@ -22,7 +22,82 @@ const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
console.error('Error checking setup status:', error);
setLoading(false);
});
}, []);
// Check for OAuth callback code in URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const error = urlParams.get('error');
const token = urlParams.get('token');
if (code && (window.location.pathname === '/auth/google/callback' || window.location.pathname === '/auth/callback')) {
// Exchange code for token
apiCall('/auth/google/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to exchange code for token');
}
return res.json();
})
.then(({ token, user }) => {
localStorage.setItem('authToken', token);
onLogin(user);
// Clean up URL and redirect to dashboard
window.history.replaceState({}, document.title, '/');
})
.catch(error => {
console.error('OAuth exchange failed:', error);
alert('Login failed. Please try again.');
// Clean up URL
window.history.replaceState({}, document.title, '/');
});
} else if (token && (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback')) {
// Direct token from URL (from backend redirect)
localStorage.setItem('authToken', token);
apiCall('/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then(user => {
onLogin(user);
// Clean up URL and redirect to dashboard
window.history.replaceState({}, document.title, '/');
})
.catch(error => {
console.error('Error getting user info:', error);
localStorage.removeItem('authToken');
// Clean up URL
window.history.replaceState({}, document.title, '/');
});
} else if (error) {
console.error('Authentication error:', error);
alert(`Login error: ${error}`);
// Clean up URL
window.history.replaceState({}, document.title, '/');
}
}, [onLogin]);
const handleGoogleLogin = async () => {
try {
// Get OAuth URL from backend
const response = await apiCall('/auth/google/url');
const { url } = await response.json();
// Redirect to Google OAuth
window.location.href = url;
} catch (error) {
console.error('Failed to get OAuth URL:', error);
alert('Login failed. Please try again.');
}
};
if (loading) {
return (
@@ -45,38 +120,55 @@ const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
{!setupStatus?.firstAdminCreated && (
<div className="setup-notice">
<h3>🚀 First Time Setup</h3>
<p>The first person to sign in will be promoted to administrator automatically.</p>
<p>The first person to log in will become the system administrator.</p>
</div>
)}
<div className="login-content">
<button
className="google-login-btn"
onClick={onLogin}
onClick={handleGoogleLogin}
disabled={false}
>
<svg className="google-icon" viewBox="0 0 24 24">
<path fill="#635dff" d="M22 12.07c0-5.52-4.48-10-10-10s-10 4.48-10 10a9.97 9.97 0 006.85 9.48.73.73 0 00.95-.7v-3.05c-2.79.61-3.38-1.19-3.38-1.19-.46-1.17-1.12-1.49-1.12-1.49-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.37 1.09 2.96.83.09-.65.35-1.09.63-1.34-2.23-.25-4.57-1.12-4.57-4.96 0-1.1.39-2 1.03-2.7-.1-.25-.45-1.25.1-2.6 0 0 .84-.27 2.75 1.02a9.53 9.53 0 015 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.35.1 2.6.64.7 1.03 1.6 1.03 2.7 0 3.85-2.34 4.71-4.58 4.95.36.31.69.92.69 1.86v2.75c0 .39.27.71.66.79a10 10 0 007.61-9.71z"/>
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Auth0
Continue with Google
</button>
<div className="login-info">
<p>
{setupStatus?.authProvider === 'auth0'
? 'Sign in with your organisation account. We use Auth0 for secure authentication.'
: 'Authentication service is being configured. Please try again later.'}
{setupStatus?.firstAdminCreated
? "Sign in with your Google account to access the VIP Coordinator."
: "Sign in with Google to set up your administrator account."
}
</p>
</div>
{errorMessage && (
<div className="dev-login-error" style={{ marginTop: '1rem' }}>
{errorMessage}
{setupStatus && !setupStatus.setupCompleted && (
<div style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#fff3cd',
borderRadius: '6px',
border: '1px solid #ffeaa7',
fontSize: '0.9rem'
}}>
<strong> Setup Required:</strong>
<p style={{ margin: '0.5rem 0 0 0' }}>
Google OAuth credentials need to be configured. If the login doesn't work,
please follow the setup guide in <code>GOOGLE_OAUTH_SETUP.md</code> to configure
your Google Cloud Console credentials in the admin dashboard.
</p>
</div>
)}
</div>
<div className="login-footer">
<p>Secure authentication powered by Auth0</p>
<p>Secure authentication powered by Google OAuth</p>
</div>
</div>
</div>

View File

@@ -1,14 +1,9 @@
const DEFAULT_API_BASE =
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost:3000';
export const API_BASE_URL =
import.meta.env.VITE_API_URL?.replace(/\/$/, '') || DEFAULT_API_BASE;
// API Configuration
// Use environment variable with fallback to production URL
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://api.bsa.madeamess.online';
// Helper function for API calls
export const apiCall = (endpoint: string, options?: RequestInit) => {
const url = /^https?:\/\//.test(endpoint)
? endpoint
: `${API_BASE_URL}${endpoint}`;
const url = endpoint.startsWith('/') ? `${API_BASE_URL}${endpoint}` : endpoint;
return fetch(url, options);
};

View File

@@ -1,49 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom base styles */
@layer base {
:root {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
line-height: 1.6;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
color: #1e293b;
}
#root {
width: 100%;
margin: 0 auto;
text-align: left;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus styles */
*:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
@import "tailwindcss";
/* Custom base styles */
@layer base {
@@ -92,117 +47,304 @@
@layer components {
/* Modern Button Styles */
.btn {
@apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
border-radius: 0.75rem;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s;
outline: none;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
transform: translateY(0);
}
.btn:focus {
ring: 2px;
ring-offset: 2px;
}
.btn:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
transform: translateY(-0.125rem);
}
.btn-primary {
@apply bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white focus:ring-blue-500;
background: linear-gradient(to right, #3b82f6, #2563eb);
color: white;
}
.btn-primary:hover {
background: linear-gradient(to right, #2563eb, #1d4ed8);
}
.btn-primary:focus {
ring-color: #3b82f6;
}
.btn-secondary {
@apply bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white focus:ring-slate-500;
background: linear-gradient(to right, #64748b, #475569);
color: white;
}
.btn-secondary:hover {
background: linear-gradient(to right, #475569, #334155);
}
.btn-secondary:focus {
ring-color: #64748b;
}
.btn-danger {
@apply bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white focus:ring-red-500;
background: linear-gradient(to right, #ef4444, #dc2626);
color: white;
}
.btn-danger:hover {
background: linear-gradient(to right, #dc2626, #b91c1c);
}
.btn-danger:focus {
ring-color: #ef4444;
}
.btn-success {
@apply bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white focus:ring-green-500;
background: linear-gradient(to right, #22c55e, #16a34a);
color: white;
}
.btn-success:hover {
background: linear-gradient(to right, #16a34a, #15803d);
}
.btn-success:focus {
ring-color: #22c55e;
}
/* Modern Card Styles */
.card {
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
background-color: white;
border-radius: 1rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(226, 232, 240, 0.6);
overflow: hidden;
backdrop-filter: blur(4px);
}
/* Modern Form Styles */
.form-group {
@apply mb-6;
margin-bottom: 1.5rem;
}
.form-label {
@apply block text-sm font-semibold text-slate-700 mb-3;
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #334155;
margin-bottom: 0.75rem;
}
.form-input {
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white;
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
background-color: white;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
border-color: #3b82f6;
}
.form-select {
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white transition-all duration-200;
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
background-color: white;
transition: all 0.2s;
}
.form-select:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
border-color: #3b82f6;
}
.form-textarea {
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white resize-none;
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
background-color: white;
transition: all 0.2s;
resize: none;
}
.form-textarea:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
border-color: #3b82f6;
}
.form-checkbox {
@apply w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500 focus:ring-2;
width: 1.25rem;
height: 1.25rem;
color: #2563eb;
border: 1px solid #cbd5e1;
border-radius: 0.25rem;
}
.form-checkbox:focus {
ring: 2px;
ring-color: #3b82f6;
}
.form-radio {
@apply w-4 h-4 text-blue-600 border-slate-300 focus:ring-blue-500 focus:ring-2;
width: 1rem;
height: 1rem;
color: #2563eb;
border: 1px solid #cbd5e1;
}
.form-radio:focus {
ring: 2px;
ring-color: #3b82f6;
}
/* Modal Styles */
.modal-overlay {
@apply fixed inset-0 bg-black/50 backdrop-blur-sm flex justify-center items-center z-50 p-4;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
padding: 1rem;
}
.modal-content {
@apply bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto;
background-color: white;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 56rem;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
@apply bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60;
background: linear-gradient(to right, #eff6ff, #eef2ff);
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
}
.modal-body {
@apply p-8;
padding: 2rem;
}
.modal-footer {
@apply bg-slate-50 px-8 py-6 border-t border-slate-200/60 flex justify-end space-x-4;
background-color: #f8fafc;
padding: 1.5rem 2rem;
border-top: 1px solid rgba(226, 232, 240, 0.6);
display: flex;
justify-content: flex-end;
gap: 1rem;
}
/* Form Actions */
.form-actions {
@apply flex justify-end space-x-4 pt-6 border-t border-slate-200/60 mt-8;
display: flex;
justify-content: flex-end;
gap: 1rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(226, 232, 240, 0.6);
margin-top: 2rem;
}
/* Form Sections */
.form-section {
@apply bg-slate-50 rounded-xl p-6 mb-6 border border-slate-200/60;
background-color: #f8fafc;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(226, 232, 240, 0.6);
}
.form-section-header {
@apply flex items-center justify-between mb-4;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.form-section-title {
@apply text-lg font-bold text-slate-800;
font-size: 1.125rem;
font-weight: 700;
color: #1e293b;
}
/* Radio Group */
.radio-group {
@apply flex gap-6 mt-3;
display: flex;
gap: 1.5rem;
margin-top: 0.75rem;
}
.radio-option {
@apply flex items-center cursor-pointer bg-white rounded-lg px-4 py-3 border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all duration-200;
display: flex;
align-items: center;
cursor: pointer;
background-color: white;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.radio-option:hover {
border-color: #93c5fd;
background-color: #eff6ff;
}
.radio-option.selected {
@apply border-blue-500 bg-blue-50 ring-2 ring-blue-200;
border-color: #3b82f6;
background-color: #eff6ff;
ring: 2px;
ring-color: #bfdbfe;
}
/* Checkbox Group */
.checkbox-option {
@apply flex items-center cursor-pointer bg-white rounded-lg px-4 py-3 border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all duration-200;
display: flex;
align-items: center;
cursor: pointer;
background-color: white;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.checkbox-option:hover {
border-color: #93c5fd;
background-color: #eff6ff;
}
.checkbox-option.checked {
@apply border-blue-500 bg-blue-50;
border-color: #3b82f6;
background-color: #eff6ff;
}
}

View File

@@ -1,36 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Auth0Provider } from '@auth0/auth0-react';
import App from './App.tsx';
import './index.css';
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
if (!domain || !clientId) {
throw new Error('Auth0 environment variables are missing. Please set VITE_AUTH0_DOMAIN and VITE_AUTH0_CLIENT_ID.');
}
const authorizationParams: Record<string, string> = {
redirect_uri: `${window.location.origin}/auth/callback`,
scope: 'openid profile email'
};
if (audience) {
authorizationParams.audience = audience;
}
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Auth0Provider
domain={domain}
clientId={clientId}
authorizationParams={authorizationParams}
cacheLocation="localstorage"
useRefreshTokens={true}
>
<App />
</Auth0Provider>
</React.StrictMode>
);
</React.StrictMode>,
)

View File

@@ -1,16 +1,14 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiCall, API_BASE_URL } from '../config/api';
import { apiCall } from '../config/api';
import { generateTestVips, getTestOrganizations, generateVipSchedule } from '../utils/testVipData';
interface ApiKeys {
aviationStackKey?: string;
googleMapsKey?: string;
twilioKey?: string;
auth0Domain?: string;
auth0ClientId?: string;
auth0ClientSecret?: string;
auth0Audience?: string;
googleClientId?: string;
googleClientSecret?: string;
}
interface SystemSettings {
@@ -22,89 +20,92 @@ interface SystemSettings {
const AdminDashboard: React.FC = () => {
const navigate = useNavigate();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [adminPassword, setAdminPassword] = useState('');
const [apiKeys, setApiKeys] = useState<ApiKeys>({});
const [systemSettings, setSystemSettings] = useState<SystemSettings>({});
const [testResults, setTestResults] = useState<{ [key: string]: string }>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const [showKeys, setShowKeys] = useState<{ [key: string]: boolean }>({});
const [savedKeys, setSavedKeys] = useState<{ [key: string]: boolean }>({});
const [maskedKeyHints, setMaskedKeyHints] = useState<{ [key: string]: string }>({});
const [testDataLoading, setTestDataLoading] = useState(false);
const [testDataStatus, setTestDataStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const buildAuthHeaders = (includeJson = false) => {
const headers: Record<string, string> = {};
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
useEffect(() => {
// Check if already authenticated
const authStatus = sessionStorage.getItem('adminAuthenticated');
if (authStatus === 'true') {
setIsAuthenticated(true);
loadSettings();
}
if (includeJson) {
headers['Content-Type'] = 'application/json';
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('/api/admin/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPassword })
});
if (response.ok) {
setIsAuthenticated(true);
sessionStorage.setItem('adminAuthenticated', 'true');
loadSettings();
} else {
alert('Invalid admin password');
}
} catch (error) {
alert('Authentication failed');
}
return headers;
};
const loadSettings = async () => {
try {
setLoading(true);
setError(null);
const response = await apiCall('/api/admin/settings', {
headers: buildAuthHeaders()
const response = await fetch('/api/admin/settings', {
headers: {
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
}
});
if (response.ok) {
const data = await response.json();
// Track which keys are already saved (masked keys start with ***)
const saved: { [key: string]: boolean } = {};
const maskedHints: { [key: string]: string } = {};
const cleanedApiKeys: ApiKeys = {};
if (data.apiKeys) {
Object.entries(data.apiKeys).forEach(([key, value]) => {
if (value && (value as string).startsWith('***')) {
saved[key] = true;
maskedHints[key] = value as string;
} else if (value) {
}
});
}
setSavedKeys(saved);
// Don't load masked keys as actual values - keep them empty
const cleanedApiKeys: ApiKeys = {};
if (data.apiKeys) {
Object.entries(data.apiKeys).forEach(([key, value]) => {
// Only set the value if it's not a masked key
if (value && !(value as string).startsWith('***')) {
cleanedApiKeys[key as keyof ApiKeys] = value as string;
}
});
}
setSavedKeys(saved);
setMaskedKeyHints(maskedHints);
setApiKeys(cleanedApiKeys);
setSystemSettings(data.systemSettings || {});
} else if (response.status === 403) {
setError('You need administrator access to view this page.');
} else if (response.status === 401) {
setError('Authentication required. Please sign in again.');
} else {
setError('Failed to load admin settings.');
}
} catch (err) {
console.error('Failed to load settings:', err);
setError('Failed to load admin settings.');
} finally {
setLoading(false);
} catch (error) {
console.error('Failed to load settings:', error);
}
};
useEffect(() => {
loadSettings();
}, []);
const handleApiKeyChange = (key: keyof ApiKeys, value: string) => {
setApiKeys(prev => ({ ...prev, [key]: value }));
// If user is typing a new key, mark it as not saved anymore
if (value && !value.startsWith('***')) {
setSavedKeys(prev => ({ ...prev, [key]: false }));
setMaskedKeyHints(prev => {
const next = { ...prev };
delete next[key];
return next;
});
}
};
@@ -116,9 +117,12 @@ const AdminDashboard: React.FC = () => {
setTestResults(prev => ({ ...prev, [apiType]: 'Testing...' }));
try {
const response = await apiCall(`/api/admin/test-api/${apiType}`, {
const response = await fetch(`/api/admin/test-api/${apiType}`, {
method: 'POST',
headers: buildAuthHeaders(true),
headers: {
'Content-Type': 'application/json',
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
},
body: JSON.stringify({
apiKey: apiKeys[apiType as keyof ApiKeys]
})
@@ -146,13 +150,16 @@ const AdminDashboard: React.FC = () => {
};
const saveSettings = async () => {
setSaving(true);
setLoading(true);
setSaveStatus(null);
try {
const response = await apiCall('/api/admin/settings', {
const response = await fetch('/api/admin/settings', {
method: 'POST',
headers: buildAuthHeaders(true),
headers: {
'Content-Type': 'application/json',
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
},
body: JSON.stringify({
apiKeys,
systemSettings
@@ -161,8 +168,16 @@ const AdminDashboard: React.FC = () => {
if (response.ok) {
setSaveStatus('Settings saved successfully!');
// Refresh the latest settings so saved states/labels stay accurate
await loadSettings();
// Mark keys as saved if they have values
const newSavedKeys: { [key: string]: boolean } = {};
Object.entries(apiKeys).forEach(([key, value]) => {
if (value && !value.startsWith('***')) {
newSavedKeys[key] = true;
}
});
setSavedKeys(prev => ({ ...prev, ...newSavedKeys }));
// Clear the input fields after successful save
setApiKeys({});
setTimeout(() => setSaveStatus(null), 3000);
} else {
setSaveStatus('Failed to save settings');
@@ -170,14 +185,14 @@ const AdminDashboard: React.FC = () => {
} catch (error) {
setSaveStatus('Error saving settings');
} finally {
setSaving(false);
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem('authToken');
sessionStorage.removeItem('adminAuthenticated');
setIsAuthenticated(false);
navigate('/');
window.location.reload();
};
// Test VIP functions
@@ -337,29 +352,37 @@ const AdminDashboard: React.FC = () => {
}
};
if (loading) {
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
<div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4 border border-slate-200/60">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-lg font-medium text-slate-700">Loading admin settings...</span>
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md border border-slate-200/60">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center">
<div className="w-4 h-4 bg-amber-500 rounded-full"></div>
</div>
</div>
);
}
<h2 className="text-2xl font-bold text-slate-800">Admin Login</h2>
<p className="text-slate-600 mt-2">Enter your admin password to continue</p>
</div>
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-xl border border-rose-200/70">
<h2 className="text-2xl font-bold text-rose-700 mb-4">Admin access required</h2>
<p className="text-slate-600 mb-6">{error}</p>
<button
className="btn btn-primary"
onClick={() => navigate('/')}
>
Return to dashboard
<form onSubmit={handleLogin} className="space-y-6">
<div className="form-group">
<label htmlFor="password" className="form-label">Admin Password</label>
<input
type="password"
id="password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className="form-input"
placeholder="Enter admin password"
required
/>
</div>
<button type="submit" className="btn btn-primary w-full">
Login
</button>
</form>
</div>
</div>
);
@@ -415,20 +438,24 @@ const AdminDashboard: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-end">
<div className="lg:col-span-2">
<label className="form-label">API Key</label>
<div className="relative">
<input
type="password"
placeholder={savedKeys.aviationStackKey && maskedKeyHints.aviationStackKey
? `Saved (${maskedKeyHints.aviationStackKey.slice(-4)})`
: 'Enter AviationStack API key'}
type={showKeys.aviationStackKey ? 'text' : 'password'}
placeholder={savedKeys.aviationStackKey ? 'Key saved (enter new key to update)' : 'Enter AviationStack API key'}
value={apiKeys.aviationStackKey || ''}
onChange={(e) => handleApiKeyChange('aviationStackKey', e.target.value)}
className="form-input"
className="form-input pr-12"
/>
{savedKeys.aviationStackKey && maskedKeyHints.aviationStackKey && !apiKeys.aviationStackKey && (
<p className="text-xs text-slate-500 mt-1">
Currently saved key ends with {maskedKeyHints.aviationStackKey.slice(-4)}. Enter a new value to replace it.
</p>
{savedKeys.aviationStackKey && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, aviationStackKey: !prev.aviationStackKey }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.aviationStackKey ? 'Hide' : 'Show'}
</button>
)}
</div>
<p className="text-xs text-slate-500 mt-1">
Get your key from: https://aviationstack.com/dashboard
</p>
@@ -457,11 +484,11 @@ const AdminDashboard: React.FC = () => {
</div>
</div>
{/* Auth0 Credentials */}
{/* Google OAuth Credentials */}
<div className="form-section">
<div className="form-section-header">
<h3 className="form-section-title">Auth0 Configuration</h3>
{(savedKeys.auth0Domain || savedKeys.auth0ClientId || savedKeys.auth0ClientSecret) && (
<h3 className="form-section-title">Google OAuth Credentials</h3>
{(savedKeys.googleClientId && savedKeys.googleClientSecret) && (
<span className="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
Configured
</span>
@@ -469,79 +496,60 @@ const AdminDashboard: React.FC = () => {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-group">
<label className="form-label">Auth0 Domain</label>
<input
type="text"
placeholder={savedKeys.auth0Domain && maskedKeyHints.auth0Domain
? `Saved (${maskedKeyHints.auth0Domain.slice(-4)})`
: 'e.g. dev-1234abcd.us.auth0.com'}
value={apiKeys.auth0Domain || ''}
onChange={(e) => handleApiKeyChange('auth0Domain', e.target.value)}
className="form-input"
/>
</div>
<div className="form-group">
<label className="form-label">Client ID</label>
<div className="relative">
<input
type="password"
placeholder={savedKeys.auth0ClientId && maskedKeyHints.auth0ClientId
? `Saved (${maskedKeyHints.auth0ClientId.slice(-4)})`
: 'Enter Auth0 application Client ID'}
value={apiKeys.auth0ClientId || ''}
onChange={(e) => handleApiKeyChange('auth0ClientId', e.target.value)}
className="form-input"
type={showKeys.googleClientId ? 'text' : 'password'}
placeholder={savedKeys.googleClientId ? 'Client ID saved' : 'Enter Google OAuth Client ID'}
value={apiKeys.googleClientId || ''}
onChange={(e) => handleApiKeyChange('googleClientId', e.target.value)}
className="form-input pr-12"
/>
{savedKeys.auth0ClientId && maskedKeyHints.auth0ClientId && !apiKeys.auth0ClientId && (
<p className="text-xs text-slate-500 mt-1">
Saved client ID ends with {maskedKeyHints.auth0ClientId.slice(-4)}. Provide a new ID to update it.
</p>
{savedKeys.googleClientId && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, googleClientId: !prev.googleClientId }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.googleClientId ? 'Hide' : 'Show'}
</button>
)}
</div>
</div>
<div className="form-group">
<label className="form-label">Client Secret</label>
<div className="relative">
<input
type="password"
placeholder={savedKeys.auth0ClientSecret && maskedKeyHints.auth0ClientSecret
? `Saved (${maskedKeyHints.auth0ClientSecret.slice(-4)})`
: 'Enter Auth0 application Client Secret'}
value={apiKeys.auth0ClientSecret || ''}
onChange={(e) => handleApiKeyChange('auth0ClientSecret', e.target.value)}
className="form-input"
type={showKeys.googleClientSecret ? 'text' : 'password'}
placeholder={savedKeys.googleClientSecret ? 'Client Secret saved' : 'Enter Google OAuth Client Secret'}
value={apiKeys.googleClientSecret || ''}
onChange={(e) => handleApiKeyChange('googleClientSecret', e.target.value)}
className="form-input pr-12"
/>
{savedKeys.auth0ClientSecret && maskedKeyHints.auth0ClientSecret && !apiKeys.auth0ClientSecret && (
<p className="text-xs text-slate-500 mt-1">
Saved client secret ends with {maskedKeyHints.auth0ClientSecret.slice(-4)}. Provide a new secret to rotate it.
</p>
{savedKeys.googleClientSecret && (
<button
type="button"
onClick={() => setShowKeys(prev => ({ ...prev, googleClientSecret: !prev.googleClientSecret }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.googleClientSecret ? 'Hide' : 'Show'}
</button>
)}
</div>
<div className="form-group">
<label className="form-label">API Audience (Identifier)</label>
<input
type="text"
placeholder={apiKeys.auth0Audience || 'https://your-api-identifier'}
value={apiKeys.auth0Audience || ''}
onChange={(e) => handleApiKeyChange('auth0Audience', e.target.value)}
className="form-input"
/>
<p className="text-xs text-slate-500 mt-1">
Create an API in Auth0 and use its Identifier here (e.g. https://vip-coordinator-api).
</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
<h4 className="font-semibold text-blue-900 mb-2">Setup Instructions</h4>
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
<li>Sign in to the Auth0 Dashboard</li>
<li>Create a <strong>Single Page Application</strong> for the frontend</li>
<li>Set Allowed Callback URL to <code>https://bsa.madeamess.online/auth/callback</code></li>
<li>Set Allowed Logout URL to <code>https://bsa.madeamess.online/</code></li>
<li>Set Allowed Web Origins to <code>https://bsa.madeamess.online</code></li>
<li>Create an <strong>API</strong> in Auth0 for the backend and use its Identifier as the audience</li>
<li>Go to Google Cloud Console</li>
<li>Create or select a project</li>
<li>Enable the Google+ API</li>
<li>Go to "Credentials" "Create Credentials" "OAuth 2.0 Client IDs"</li>
<li>Set authorized redirect URI: http://bsa.madeamess.online:3000/auth/google/callback</li>
<li>Set authorized JavaScript origins: http://bsa.madeamess.online:5173</li>
</ol>
</div>
</div>
@@ -751,7 +759,7 @@ const AdminDashboard: React.FC = () => {
</p>
<button
className="btn btn-primary w-full mb-2"
onClick={() => window.open(`${API_BASE_URL}/api-docs.html`, '_blank')}
onClick={() => window.open('http://localhost:3000/api-docs.html', '_blank')}
>
Open API Documentation
</button>
@@ -803,9 +811,9 @@ const AdminDashboard: React.FC = () => {
<button
className="btn btn-success text-lg px-8 py-4"
onClick={saveSettings}
disabled={saving}
disabled={loading}
>
{saving ? 'Saving...' : 'Save All Settings'}
{loading ? 'Saving...' : 'Save All Settings'}
</button>
{saveStatus && (

View File

@@ -8,5 +8,4 @@ export default {
extend: {},
},
plugins: [],
};
}

View File

@@ -3,9 +3,11 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
],
css: {
postcss: './postcss.config.js',
postcss: './postcss.config.mjs',
},
server: {
host: '0.0.0.0',
@@ -45,10 +47,6 @@ export default defineConfig({
target: 'http://backend:3000',
changeOrigin: true,
},
'/auth/dev-login': {
target: 'http://backend:3000',
changeOrigin: true,
},
},
},
})