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 2e12ff90c3
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 ### 🔐 Authentication & User Management
- **Driver Coordination**: Assign and track drivers with real-time location updates - **Google OAuth Integration**: Secure login with Google accounts
- **Flight Tracking**: Monitor flight status and arrival times - **Role-Based Access Control**: Administrator, Coordinator, and Driver roles
- **Schedule Management**: Organize VIP itineraries and driver assignments - **User Approval System**: Admin approval required for new users
- **Real-time Dashboard**: Overview of all active VIPs and available drivers - **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 ### Backend
- Node.js with Express.js - **Node.js + Express.js**: RESTful API server
- TypeScript for type safety - **TypeScript**: Full type safety
- PostgreSQL database - **PostgreSQL**: Persistent data storage with automatic schema management
- Redis for caching and real-time updates - **Redis**: Caching and real-time updates
- Docker containerization - **JWT Authentication**: Secure, stateless authentication
- **Google OAuth 2.0**: Simple, secure user authentication
### Frontend ### Frontend
- React 18 with TypeScript - **React 18 + TypeScript**: Modern, type-safe frontend
- Vite for fast development - **Vite**: Lightning-fast development server
- React Router for navigation - **Tailwind CSS v4**: Modern utility-first styling
- Leaflet for mapping (planned) - **React Router**: Client-side routing
- Responsive design with CSS Grid/Flexbox - **Responsive Design**: Mobile-friendly interface
## Getting Started ## 🚀 Quick Start
### Prerequisites ### Prerequisites
- Docker and Docker Compose - Docker and Docker Compose
- Node.js 18+ (for local development) - Google Cloud Console account (for OAuth setup)
- npm or yarn
### Quick Start with Docker ### 1. Start the Application
1. Clone the repository and navigate to the project directory:
```bash ```bash
git clone <repository-url>
cd vip-coordinator cd vip-coordinator
```
2. Start the development environment:
```bash
make dev make dev
``` ```
This will start all services: **Services will be available at:**
- Frontend: http://localhost:5173 - 🌐 **Frontend**: http://localhost:5173
- Backend API: http://localhost:3000 - 🔌 **Backend API**: http://localhost:3000
- PostgreSQL: localhost:5432 - 📚 **API Documentation**: http://localhost:3000/api-docs.html
- Redis: localhost:6379 - 🏥 **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 ```bash
cd backend # Start development environment
npm install make dev
npm run 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 ### Project Structure
```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
``` ```
vip-coordinator/ vip-coordinator/
├── backend/ ├── backend/ # Node.js API server
│ ├── src/ │ ├── src/
│ │ ├── routes/ # API route handlers
│ │ ├── services/ # Business logic services
│ │ ├── config/ # Configuration and auth
│ │ └── index.ts # Main server file │ │ └── index.ts # Main server file
│ ├── package.json │ ├── package.json
│ ├── tsconfig.json │ ├── tsconfig.json
│ └── Dockerfile │ └── Dockerfile
├── frontend/ ├── frontend/ # React frontend
│ ├── src/ │ ├── src/
│ │ ├── components/ # Reusable components │ │ ├── components/ # Reusable UI components
│ │ ├── pages/ # Page components │ │ ├── pages/ # Page components
│ │ ├── types/ # TypeScript types │ │ ├── config/ # API configuration
│ │ ├── App.tsx # Main app component │ │ ├── App.tsx # Main app component
│ │ └── main.tsx # Entry point │ │ └── main.tsx # Entry point
│ ├── package.json │ ├── package.json
@@ -109,47 +144,91 @@ vip-coordinator/
├── docker-compose.dev.yml # Development environment ├── docker-compose.dev.yml # Development environment
├── docker-compose.prod.yml # Production environment ├── docker-compose.prod.yml # Production environment
├── Makefile # Development commands ├── 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 ```bash
# Start development environment
make dev make dev
```
### Production
```bash
# Build production images # Build production images
make build make build
# Deploy to production # Deploy with production configuration
make deploy docker-compose -f docker-compose.prod.yml up -d
# Backend only
cd backend && npm run dev
# Frontend only
cd frontend && npm run dev
``` ```
## 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 - [ ] Real-time GPS tracking for drivers
- [ ] Flight API integration for live updates
- [ ] Push notifications for schedule changes - [ ] Push notifications for schedule changes
- [ ] Google Sheets import/export - [ ] Mobile driver application
- [ ] Mobile-responsive driver app - [ ] Advanced reporting and analytics
- [ ] Advanced scheduling with drag-and-drop - [ ] Google Sheets integration
- [ ] Reporting and analytics
- [ ] Multi-tenant support - [ ] Multi-tenant support
- [ ] Advanced mapping features
## Contributing ## 🤝 Contributing
1. Fork the repository 1. Fork the repository
2. Create a feature branch 2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Make your changes 3. Make your changes and test thoroughly
4. Test thoroughly 4. Commit your changes: `git commit -m 'Add amazing feature'`
5. Submit a pull request 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 Configuration
DATABASE_URL=postgresql://postgres:password@db:5432/vip_coordinator DATABASE_URL=postgresql://postgres:changeme@db:5432/vip_coordinator
# Redis Configuration # Redis Configuration
REDIS_URL=redis://redis:6379 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 JWT_SECRET=your-super-secure-jwt-secret-key-change-in-production-12345
SESSION_SECRET=your-super-secure-session-secret-change-in-production-67890 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_ID=308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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 GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=https://api.bsa.madeamess.online/auth/google/callback 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 Configuration
ADMIN_PASSWORD=admin123 ADMIN_PASSWORD=admin123
# Port Configuration
PORT=3000

View File

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

View File

@@ -13,7 +13,6 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"redis": "^4.6.8", "redis": "^4.6.8",
"uuid": "^9.0.0" "uuid": "^9.0.0"
@@ -80,7 +79,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0", "generic-pool": "3.9.0",
@@ -154,6 +152,7 @@
"version": "1.19.5", "version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
"@types/node": "*" "@types/node": "*"
@@ -163,6 +162,7 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -180,6 +180,7 @@
"version": "4.17.22", "version": "4.17.22",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz",
"integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==", "integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==",
"dev": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33", "@types/express-serve-static-core": "^4.17.33",
@@ -191,6 +192,7 @@
"version": "4.19.6", "version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"@types/qs": "*", "@types/qs": "*",
@@ -201,12 +203,14 @@
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "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": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/ms": "*", "@types/ms": "*",
@@ -216,19 +220,21 @@
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "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": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "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,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.17.57", "version": "20.17.57",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
"integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==", "integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==",
"peer": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
} }
@@ -248,17 +254,20 @@
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "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": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "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": { "node_modules/@types/send": {
"version": "0.17.4", "version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dev": true,
"dependencies": { "dependencies": {
"@types/mime": "^1", "@types/mime": "^1",
"@types/node": "*" "@types/node": "*"
@@ -268,6 +277,7 @@
"version": "1.15.7", "version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
"dev": true,
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
"@types/node": "*", "@types/node": "*",
@@ -1016,15 +1026,6 @@
"node": ">=0.12.0" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -1064,46 +1065,6 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/jws": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
@@ -1114,17 +1075,6 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1167,28 +1117,6 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT" "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": { "node_modules/make-error": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "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", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==", "integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.0", "pg-connection-string": "^2.9.0",
"pg-pool": "^3.10.0", "pg-pool": "^3.10.0",
@@ -1990,7 +1917,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2002,7 +1928,8 @@
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.19.8", "version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
@@ -2119,7 +2046,6 @@
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"peer": true,
"requires": { "requires": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0", "generic-pool": "3.9.0",
@@ -2178,6 +2104,7 @@
"version": "1.19.5", "version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"requires": { "requires": {
"@types/connect": "*", "@types/connect": "*",
"@types/node": "*" "@types/node": "*"
@@ -2187,6 +2114,7 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"requires": { "requires": {
"@types/node": "*" "@types/node": "*"
} }
@@ -2204,6 +2132,7 @@
"version": "4.17.22", "version": "4.17.22",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz",
"integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==", "integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==",
"dev": true,
"requires": { "requires": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33", "@types/express-serve-static-core": "^4.17.33",
@@ -2215,6 +2144,7 @@
"version": "4.19.6", "version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"dev": true,
"requires": { "requires": {
"@types/node": "*", "@types/node": "*",
"@types/qs": "*", "@types/qs": "*",
@@ -2225,12 +2155,14 @@
"@types/http-errors": { "@types/http-errors": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "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": { "@types/jsonwebtoken": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
"dev": true,
"requires": { "requires": {
"@types/ms": "*", "@types/ms": "*",
"@types/node": "*" "@types/node": "*"
@@ -2239,18 +2171,20 @@
"@types/mime": { "@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "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": { "@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "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": { "@types/node": {
"version": "20.17.57", "version": "20.17.57",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
"integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==", "integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==",
"peer": true, "dev": true,
"requires": { "requires": {
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
} }
@@ -2269,17 +2203,20 @@
"@types/qs": { "@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "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": { "@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "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": { "@types/send": {
"version": "0.17.4", "version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dev": true,
"requires": { "requires": {
"@types/mime": "^1", "@types/mime": "^1",
"@types/node": "*" "@types/node": "*"
@@ -2289,6 +2226,7 @@
"version": "1.15.7", "version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
"dev": true,
"requires": { "requires": {
"@types/http-errors": "*", "@types/http-errors": "*",
"@types/node": "*", "@types/node": "*",
@@ -2841,11 +2779,6 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true "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": { "jsonwebtoken": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -2880,34 +2813,6 @@
"safe-buffer": "^5.0.1" "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": { "jws": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
@@ -2917,16 +2822,6 @@
"safe-buffer": "^5.0.1" "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": { "lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "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", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" "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": { "make-error": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -3113,7 +2991,6 @@
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==", "integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
"peer": true,
"requires": { "requires": {
"pg-cloudflare": "^1.2.5", "pg-cloudflare": "^1.2.5",
"pg-connection-string": "^2.9.0", "pg-connection-string": "^2.9.0",
@@ -3509,13 +3386,13 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true
"peer": true
}, },
"undici-types": { "undici-types": {
"version": "6.19.8", "version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "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": { "unpipe": {
"version": "1.0.0", "version": "1.0.0",

View File

@@ -5,7 +5,7 @@
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts", "dev": "npx tsx src/index.ts",
"build": "tsc", "build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
@@ -22,7 +22,6 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"redis": "^4.6.8", "redis": "^4.6.8",
"uuid": "^9.0.0" "uuid": "^9.0.0"
@@ -34,7 +33,9 @@
"@types/node": "^20.5.0", "@types/node": "^20.5.0",
"@types/pg": "^8.10.2", "@types/pg": "^8.10.2",
"@types/uuid": "^9.0.2", "@types/uuid": "^9.0.2",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0", "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(); dotenv.config();
const useSSL = process.env.DATABASE_SSL === 'true';
const pool = new Pool({ const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:changeme@localhost:5432/vip_coordinator', connectionString: process.env.DATABASE_URL || 'postgresql://postgres:changeme@localhost:5432/vip_coordinator',
ssl: useSSL ? { rejectUnauthorized: false } : false,
max: 20, max: 20,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000, connectionTimeoutMillis: 2000,

View File

@@ -1,178 +1,134 @@
import jwt, { JwtHeader, JwtPayload } from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const auth0Domain = process.env.AUTH0_DOMAIN; const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const auth0Audience = process.env.AUTH0_AUDIENCE;
if (!auth0Domain) { export interface User {
console.warn('⚠️ AUTH0_DOMAIN is not set. Authentication routes will reject requests until configured.'); 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) { export function generateToken(user: User): string {
console.warn('⚠️ AUTH0_AUDIENCE is not set. Authentication routes will reject requests until configured.'); 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 export function verifyToken(token: string): User | null {
? jwksClient({ try {
jwksUri: `https://${auth0Domain}/.well-known/jwks.json`, const decoded = jwt.verify(token, JWT_SECRET) as any;
cache: true, return {
cacheMaxEntries: 5, id: decoded.id,
cacheMaxAge: 10 * 60 * 1000 google_id: decoded.google_id,
}) email: decoded.email,
: null; name: decoded.name,
profile_picture_url: decoded.profile_picture_url,
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes role: decoded.role
const profileCache = new Map<string, { profile: Auth0UserProfile; expiresAt: number }>(); };
const inflightProfileRequests = new Map<string, Promise<Auth0UserProfile>>(); } catch (error) {
return null;
export interface Auth0UserProfile { }
sub: string;
email?: string;
name?: string;
nickname?: string;
picture?: string;
[key: string]: unknown;
} }
export interface VerifiedAccessToken extends JwtPayload { // Simple Google OAuth2 client using fetch
sub: string; export async function verifyGoogleToken(googleToken: string): Promise<any> {
azp?: string; try {
scope?: string; 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> { // Get Google OAuth2 URL
if (!jwks) { export function getGoogleAuthUrl(): string {
throw new Error('Auth0 JWKS client not initialised'); 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) { const params = new URLSearchParams({
throw new Error('Token signing key id (kid) is missing'); client_id: clientId,
} redirect_uri: redirectUri,
response_type: 'code',
const signingKey = await new Promise<jwksClient.SigningKey>((resolve, reject) => { scope: 'openid email profile',
jwks.getSigningKey(header.kid as string, (err, key) => { access_type: 'offline',
if (err) { prompt: 'consent'
return reject(err);
}
if (!key) {
return reject(new Error('Signing key not found'));
}
resolve(key);
});
}); });
const publicKey = return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
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 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> { try {
if (!auth0Domain || !auth0Audience) { const response = await fetch('https://oauth2.googleapis.com/token', {
throw new Error('Auth0 configuration is incomplete'); method: 'POST',
}
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`, {
headers: { 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) { 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; return await response.json();
profileCache.set(cacheKey, { profile, expiresAt: now + ttl }); } catch (error) {
inflightProfileRequests.delete(cacheKey); console.error('Error exchanging code for tokens:', error);
return profile;
})().catch(error => {
inflightProfileRequests.delete(cacheKey);
throw 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 { // Get user info from Google
const cached = profileCache.get(cacheKey); export async function getGoogleUserInfo(accessToken: string): Promise<any> {
if (cached && cached.expiresAt > Date.now()) { try {
return cached.profile; const response = await fetch(`https://www.googleapis.com/oauth2/v2/userinfo?access_token=${accessToken}`);
}
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}`
}
});
if (!response.ok) { 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: [ origin: [
process.env.FRONTEND_URL || 'http://localhost:5173', process.env.FRONTEND_URL || 'http://localhost:5173',
'https://bsa.madeamess.online:5173', 'https://bsa.madeamess.online:5173',
'https://bsa.madeamess.online',
'https://api.bsa.madeamess.online',
'http://bsa.madeamess.online:5173' 'http://bsa.madeamess.online:5173'
], ],
credentials: true 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! // 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 // Initialize flight tracking scheduler
const flightTracker = new FlightTrackingScheduler(flightService); const flightTracker = new FlightTrackingScheduler(flightService);
@@ -606,21 +611,35 @@ app.get('/api/drivers/:driverId/schedule', requireAuth, async (req: Request, res
}); });
// Admin routes // 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 { try {
const adminSettings = await enhancedDataService.getAdminSettings(); const adminSettings = await enhancedDataService.getAdminSettings();
const apiKeys = adminSettings.apiKeys || {};
// Return settings but mask API keys for display only // Return settings but mask API keys for display only
// IMPORTANT: Don't return the actual keys, just indicate they exist // IMPORTANT: Don't return the actual keys, just indicate they exist
const maskedSettings = { const maskedSettings = {
apiKeys: { apiKeys: {
aviationStackKey: apiKeys.aviationStackKey ? '***' + apiKeys.aviationStackKey.slice(-4) : '', aviationStackKey: adminSettings.apiKeys.aviationStackKey ? '***' + adminSettings.apiKeys.aviationStackKey.slice(-4) : '',
googleMapsKey: apiKeys.googleMapsKey ? '***' + apiKeys.googleMapsKey.slice(-4) : '', googleMapsKey: adminSettings.apiKeys.googleMapsKey ? '***' + adminSettings.apiKeys.googleMapsKey.slice(-4) : '',
twilioKey: apiKeys.twilioKey ? '***' + apiKeys.twilioKey.slice(-4) : '', twilioKey: adminSettings.apiKeys.twilioKey ? '***' + adminSettings.apiKeys.twilioKey.slice(-4) : '',
auth0Domain: apiKeys.auth0Domain ? '***' + apiKeys.auth0Domain.slice(-4) : '', googleClientId: adminSettings.apiKeys.googleClientId ? '***' + adminSettings.apiKeys.googleClientId.slice(-4) : '',
auth0ClientId: apiKeys.auth0ClientId ? '***' + apiKeys.auth0ClientId.slice(-4) : '', googleClientSecret: adminSettings.apiKeys.googleClientSecret ? '***' + adminSettings.apiKeys.googleClientSecret.slice(-4) : ''
auth0ClientSecret: apiKeys.auth0ClientSecret ? '***' + apiKeys.auth0ClientSecret.slice(-4) : ''
}, },
systemSettings: adminSettings.systemSettings 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 { try {
const { apiKeys, systemSettings } = req.body; const { apiKeys, systemSettings } = req.body;
const currentSettings = await enhancedDataService.getAdminSettings(); const currentSettings = await enhancedDataService.getAdminSettings();
currentSettings.apiKeys = currentSettings.apiKeys || {};
// Update API keys (only if provided and not masked) // Update API keys (only if provided and not masked)
if (apiKeys) { if (apiKeys) {
@@ -650,17 +674,15 @@ app.post('/api/admin/settings', requireAuth, requireRole(['administrator']), asy
if (apiKeys.twilioKey && !apiKeys.twilioKey.startsWith('***')) { if (apiKeys.twilioKey && !apiKeys.twilioKey.startsWith('***')) {
currentSettings.apiKeys.twilioKey = apiKeys.twilioKey; currentSettings.apiKeys.twilioKey = apiKeys.twilioKey;
} }
if (apiKeys.auth0Domain && !apiKeys.auth0Domain.startsWith('***')) { if (apiKeys.googleClientId && !apiKeys.googleClientId.startsWith('***')) {
currentSettings.apiKeys.auth0Domain = apiKeys.auth0Domain; currentSettings.apiKeys.googleClientId = apiKeys.googleClientId;
process.env.AUTH0_DOMAIN = apiKeys.auth0Domain; // Update the environment variable for Google OAuth
process.env.GOOGLE_CLIENT_ID = apiKeys.googleClientId;
} }
if (apiKeys.auth0ClientId && !apiKeys.auth0ClientId.startsWith('***')) { if (apiKeys.googleClientSecret && !apiKeys.googleClientSecret.startsWith('***')) {
currentSettings.apiKeys.auth0ClientId = apiKeys.auth0ClientId; currentSettings.apiKeys.googleClientSecret = apiKeys.googleClientSecret;
process.env.AUTH0_CLIENT_ID = apiKeys.auth0ClientId; // Update the environment variable for Google OAuth
} process.env.GOOGLE_CLIENT_SECRET = apiKeys.googleClientSecret;
if (apiKeys.auth0ClientSecret && !apiKeys.auth0ClientSecret.startsWith('***')) {
currentSettings.apiKeys.auth0ClientSecret = apiKeys.auth0ClientSecret;
process.env.AUTH0_CLIENT_SECRET = apiKeys.auth0ClientSecret;
} }
} }
@@ -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 { apiType } = req.params;
const { apiKey } = req.body; const { apiKey } = req.body;
@@ -727,6 +755,7 @@ async function startServer() {
// Start the server // Start the server
app.listen(port, () => { app.listen(port, () => {
console.log(`🚀 Server is running on port ${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(`📊 Admin dashboard: http://localhost:${port === 3000 ? 5173 : port}/admin`);
console.log(`🏥 Health check: http://localhost:${port}/api/health`); console.log(`🏥 Health check: http://localhost:${port}/api/health`);
console.log(`📚 API docs: http://localhost:${port}/api-docs.html`); console.log(`📚 API docs: http://localhost:${port}/api-docs.html`);

View File

@@ -1,126 +1,18 @@
import express, { Request, Response, NextFunction } from 'express'; import express, { Request, Response, NextFunction } from 'express';
import { import {
fetchAuth0UserProfile, generateToken,
isAuth0Configured, verifyToken,
verifyAccessToken, getGoogleAuthUrl,
VerifiedAccessToken, exchangeCodeForTokens,
Auth0UserProfile, getGoogleUserInfo,
getCachedProfile, User
cacheAuth0Profile
} from '../config/simpleAuth'; } from '../config/simpleAuth';
import databaseService from '../services/databaseService'; import databaseService from '../services/databaseService';
type AuthedRequest = Request & {
auth?: {
token: string;
claims: VerifiedAccessToken;
profile?: Auth0UserProfile | null;
};
user?: any;
};
const router = express.Router(); const router = express.Router();
function mapUserForResponse(user: any) { // Middleware to check authentication
return { export function requireAuth(req: Request, res: Response, next: NextFunction) {
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) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { 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 token = authHeader.substring(7);
const user = verifyToken(token);
try { if (!user) {
const claims = await verifyAccessToken(token); return res.status(401).json({ error: 'Invalid 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)
});
} }
return next(); (req as any).user = user;
} catch (error: any) { next();
console.error('Auth0 token verification failed:', error);
return res.status(401).json({ error: 'Invalid or expired token' });
}
} }
// Middleware to check role
export function requireRole(roles: string[]) { export function requireRole(roles: string[]) {
return (req: AuthedRequest, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
const user = req.user; const user = (req as any).user;
if (!user || !roles.includes(user.role)) { if (!user || !roles.includes(user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' }); 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 { try {
const userCount = await databaseService.getUserCount(); const userCount = await databaseService.getUserCount();
res.json({ res.json({
setupCompleted: isAuth0Configured(), setupCompleted: !!(clientId && clientSecret && clientId !== 'your-google-client-id-from-console'),
firstAdminCreated: userCount > 0, firstAdminCreated: userCount > 0,
oauthConfigured: isAuth0Configured(), oauthConfigured: !!(clientId && clientSecret)
authProvider: 'auth0'
}); });
} catch (error) { } catch (error) {
console.error('Error checking setup status:', 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) => { // Start Google OAuth flow
res.json({ router.get('/google', (req: Request, res: Response) => {
user: mapUserForResponse(req.user), try {
auth0: { const authUrl = getGoogleAuthUrl();
sub: req.auth?.claims.sub, res.redirect(authUrl);
scope: req.auth?.claims.scope } 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' }); 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({ res.json({
authenticated: true, 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 // USER MANAGEMENT ENDPOINTS
// List all users (admin only) // 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 { try {
const users = await databaseService.getAllUsers(); 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) { } catch (error) {
console.error('Error fetching users:', error); console.error('Error fetching users:', error);
res.status(500).json({ error: 'Failed to fetch users' }); res.status(500).json({ error: 'Failed to fetch users' });
@@ -230,7 +289,12 @@ router.patch('/users/:email/role', requireAuth, requireRole(['administrator']),
res.json({ res.json({
success: true, success: true,
user: mapUserForResponse(user) user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
}); });
} catch (error) { } catch (error) {
console.error('Error updating user role:', error); console.error('Error updating user role:', error);
@@ -239,9 +303,9 @@ router.patch('/users/:email/role', requireAuth, requireRole(['administrator']),
}); });
// Delete user (admin only) // 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 { email } = req.params;
const currentUser = req.user; const currentUser = (req as any).user;
// Prevent admin from deleting themselves // Prevent admin from deleting themselves
if (email === currentUser.email) { 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' }); 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) { } catch (error) {
console.error('Error fetching user:', error); console.error('Error fetching user:', error);
res.status(500).json({ error: 'Failed to fetch user' }); res.status(500).json({ error: 'Failed to fetch user' });
@@ -286,7 +360,16 @@ router.get('/users/pending/list', requireAuth, requireRole(['administrator']), a
try { try {
const pendingUsers = await databaseService.getPendingUsers(); 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); res.json(userList);
} catch (error) { } catch (error) {
@@ -313,7 +396,13 @@ router.patch('/users/:email/approval', requireAuth, requireRole(['administrator'
res.json({ res.json({
success: true, success: true,
message: `User ${status} successfully`, 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) { } catch (error) {
console.error('Error updating user approval:', error); console.error('Error updating user approval:', error);

View File

@@ -6,10 +6,9 @@ class DatabaseService {
private redis: RedisClientType; private redis: RedisClientType;
constructor() { constructor() {
const useSSL = process.env.DATABASE_SSL === 'true';
this.pool = new Pool({ this.pool = new Pool({
connectionString: process.env.DATABASE_URL, connectionString: process.env.DATABASE_URL,
ssl: useSSL ? { rejectUnauthorized: false } : false ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
}); });
// Initialize Redis connection // 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')) 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 // Create indexes
await this.query(` await this.query(`
CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id) CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id)
@@ -191,31 +158,6 @@ class DatabaseService {
return result.rows[0] || null; 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[]> { async getAllUsers(): Promise<any[]> {
const query = 'SELECT * FROM users ORDER BY created_at ASC'; const query = 'SELECT * FROM users ORDER BY created_at ASC';
const result = await this.query(query); 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> { async initializeVipTables(): Promise<void> {
try { 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(` await this.query(`
CREATE TABLE IF NOT EXISTS vips ( CREATE TABLE IF NOT EXISTS vips (
id VARCHAR(255) PRIMARY KEY, id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL, 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, notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
`); `);
await this.query(` // Create flights table (for VIPs with flight transport)
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
`);
await this.query(` await this.query(`
CREATE TABLE IF NOT EXISTS flights ( CREATE TABLE IF NOT EXISTS flights (
id SERIAL PRIMARY KEY, 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(` await this.query(`
CREATE TABLE IF NOT EXISTS drivers ( CREATE TABLE IF NOT EXISTS drivers (
id VARCHAR(255) PRIMARY KEY, id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL, 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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
`); `);
await this.query(` // Check and migrate schedule_events table
ALTER TABLE drivers const scheduleTableExists = await this.query(`
ADD COLUMN IF NOT EXISTS phone VARCHAR(50), SELECT EXISTS (
ADD COLUMN IF NOT EXISTS department VARCHAR(255) DEFAULT 'Office of Development', SELECT FROM information_schema.tables
ADD COLUMN IF NOT EXISTS user_id VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL 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(` await this.query(`
CREATE TABLE IF NOT EXISTS schedule_events ( CREATE TABLE IF NOT EXISTS schedule_events (
id VARCHAR(255) PRIMARY KEY, id VARCHAR(255) PRIMARY KEY,
@@ -388,42 +400,66 @@ class DatabaseService {
) )
`); `);
// Create system_setup table for tracking initial setup
await this.query(` 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(` await this.query(`
ALTER TABLE schedule_events CREATE TABLE IF NOT EXISTS admin_settings (
ADD COLUMN IF NOT EXISTS description TEXT, id SERIAL PRIMARY KEY,
ADD COLUMN IF NOT EXISTS assigned_driver_id VARCHAR(255) REFERENCES drivers(id) ON DELETE SET NULL, setting_key VARCHAR(255) UNIQUE NOT NULL,
ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'scheduled', setting_value TEXT,
ADD COLUMN IF NOT EXISTS event_type VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ADD COLUMN IF NOT EXISTS 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(` await this.query(`
CREATE INDEX IF NOT EXISTS idx_vips_transport_mode ON vips(transport_mode) CREATE OR REPLACE FUNCTION update_updated_at_column()
`); RETURNS TRIGGER AS $$
await this.query(` BEGIN
CREATE INDEX IF NOT EXISTS idx_flights_vip_id ON flights(vip_id) NEW.updated_at = CURRENT_TIMESTAMP;
`); RETURN NEW;
await this.query(` END;
CREATE INDEX IF NOT EXISTS idx_flights_date ON flights(flight_date) $$ language 'plpgsql'
`);
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)
`); `);
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) { } catch (error) {
console.error('❌ Failed to initialize VIP tables:', error); console.error('❌ Failed to initialize VIP tables:', error);
throw error;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# Multi-stage build for development and production # Multi-stage build for development and production
FROM node:18-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
@@ -15,20 +15,7 @@ CMD ["npm", "run", "dev"]
# Production stage # Production stage
FROM base AS production FROM base AS production
ARG VITE_AUTH0_DOMAIN RUN npm install
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
COPY . . COPY . .
RUN npm run build EXPOSE 5173
RUN npm prune --omit=dev CMD ["npm", "run", "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;"]

View File

@@ -1,5 +1,3 @@
worker_processes auto;
events { events {
worker_connections 1024; worker_connections 1024;
} }
@@ -9,56 +7,45 @@ http {
default_type application/octet-stream; default_type application/octet-stream;
sendfile on; sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65; 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 { server {
listen 80; listen 80;
server_name bsa.madeamess.online _; server_name localhost;
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;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
gzip on; # Handle client-side routing
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss image/svg+xml; location / {
gzip_min_length 256; try_files $uri $uri/ /index.html;
location /auth/callback {
try_files $uri /index.html;
} }
# API proxy to backend
location /api/ { location /api/ {
proxy_pass http://backend:3000/api/; proxy_pass http://backend:3000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
} }
location /auth/ { # Security headers
proxy_pass http://backend:3000/auth/; add_header X-Frame-Options "SAMEORIGIN" always;
proxy_set_header Host $host; add_header X-XSS-Protection "1; mode=block" always;
proxy_set_header X-Real-IP $remote_addr; add_header X-Content-Type-Options "nosniff" always;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; add_header Referrer-Policy "no-referrer-when-downgrade" always;
proxy_set_header X-Forwarded-Proto $scheme; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
proxy_set_header X-Forwarded-Host $host;
}
location / { # Cache static assets
try_files $uri $uri/ /index.html; 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, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "node ./node_modules/vite/bin/vite.js",
"build": "vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@auth0/auth0-react": "^2.8.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^6.15.0", "react-router-dom": "^6.15.0"
"tailwindcss": "^3.4.14",
"vite": "^4.5.14"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.4", "@types/leaflet": "^1.9.4",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.0.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.14",
"eslint": "^8.45.0", "eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.3", "eslint-plugin-react-refresh": "^0.4.14",
"postcss": "^8.4.47", "@tailwindcss/postcss": "^4.1.8",
"typescript": "^5.0.2" "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 */ /* Modern App-specific styles using Tailwind utilities */
/* Enhanced button styles */ /* Enhanced button styles */
@layer components {
.btn-modern { .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; @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 { .btn-gradient-amber {
@apply bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white; @apply bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white;
} }
}
/* Status badges */ /* Status badges */
@layer components {
.status-badge { .status-badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold; @apply inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold;
} }
@@ -41,8 +44,10 @@
.status-cancelled { .status-cancelled {
@apply bg-red-100 text-red-800 border border-red-200; @apply bg-red-100 text-red-800 border border-red-200;
} }
}
/* Card enhancements */ /* Card enhancements */
@layer components {
.card-modern { .card-modern {
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm; @apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
} }
@@ -54,8 +59,10 @@
.card-content { .card-content {
@apply p-6; @apply p-6;
} }
}
/* Loading states */ /* Loading states */
@layer components {
.loading-spinner { .loading-spinner {
@apply animate-spin rounded-full border-4 border-blue-600 border-t-transparent; @apply animate-spin rounded-full border-4 border-blue-600 border-t-transparent;
} }
@@ -67,8 +74,10 @@
.skeleton { .skeleton {
@apply animate-pulse bg-slate-200 rounded; @apply animate-pulse bg-slate-200 rounded;
} }
}
/* Form enhancements */ /* Form enhancements */
@layer components {
.form-modern { .form-modern {
@apply space-y-6; @apply space-y-6;
} }
@@ -88,8 +97,10 @@
.form-select-modern { .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; @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 */ /* Animation utilities */
@layer utilities {
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.5s ease-in-out; animation: fadeIn 0.5s ease-in-out;
} }
@@ -101,6 +112,7 @@
.animate-scale-in { .animate-scale-in {
animation: scaleIn 0.2s ease-out; animation: scaleIn 0.2s ease-out;
} }
}
@keyframes fadeIn { @keyframes fadeIn {
from { from {
@@ -149,6 +161,7 @@
} }
/* Glass morphism effect */ /* Glass morphism effect */
@layer utilities {
.glass { .glass {
@apply bg-white/80 backdrop-blur-lg border border-white/20; @apply bg-white/80 backdrop-blur-lg border border-white/20;
} }
@@ -156,8 +169,10 @@
.glass-dark { .glass-dark {
@apply bg-slate-900/80 backdrop-blur-lg border border-slate-700/20; @apply bg-slate-900/80 backdrop-blur-lg border border-slate-700/20;
} }
}
/* Hover effects */ /* Hover effects */
@layer utilities {
.hover-lift { .hover-lift {
@apply transition-transform duration-200 hover:-translate-y-1; @apply transition-transform duration-200 hover:-translate-y-1;
} }
@@ -169,3 +184,4 @@
.hover-scale { .hover-scale {
@apply transition-transform duration-200 hover:scale-105; @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 { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { apiCall } from './config/api'; import { apiCall } from './config/api';
import VipList from './pages/VipList'; import VipList from './pages/VipList';
import VipDetails from './pages/VipDetails'; import VipDetails from './pages/VipDetails';
@@ -12,102 +11,54 @@ import UserManagement from './components/UserManagement';
import Login from './components/Login'; import Login from './components/Login';
import './App.css'; import './App.css';
const AUTH0_AUDIENCE = import.meta.env.VITE_AUTH0_AUDIENCE;
function App() { function App() {
const {
isLoading: authLoading,
isAuthenticated,
loginWithRedirect,
logout,
getAccessTokenSilently,
user: auth0User,
error: authError
} = useAuth0();
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [pendingApproval, setPendingApproval] = useState(false);
useEffect(() => { useEffect(() => {
const bootstrap = async () => { // Check if user is already authenticated
if (!isAuthenticated) { const token = localStorage.getItem('authToken');
setUser(null); if (token) {
setStatusMessage(null); apiCall('/auth/me', {
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', {
headers: { 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);
}); });
} else {
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 {
setLoading(false); setLoading(false);
} }
}; }, []);
if (!authLoading) { const handleLogin = (userData: any) => {
bootstrap(); setUser(userData);
} };
}, [isAuthenticated, authLoading, getAccessTokenSilently, auth0User]);
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('authToken'); 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 ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center"> <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"> <div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4">
@@ -118,68 +69,23 @@ function App() {
); );
} }
if (pendingApproval) { // Handle OAuth callback route even when not logged in
return ( if (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback') {
<div className="min-h-screen bg-gradient-to-br from-amber-50 to-rose-50 flex justify-center items-center px-4"> return <Login onLogin={handleLogin} />;
<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>
);
} }
const beginLogin = async () => { if (!user) {
try { return <Login onLogin={handleLogin} />;
await loginWithRedirect({
authorizationParams: {
...(AUTH0_AUDIENCE ? { audience: AUTH0_AUDIENCE } : {}),
scope: 'openid profile email',
redirect_uri: `${window.location.origin}/auth/callback`
} }
});
} 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 ( return (
<Router> <Router>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50"> <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"> <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="max-w-7xl mx-auto px-6 lg:px-8">
<div className="flex justify-between items-center h-16"> <div className="flex justify-between items-center h-16">
{/* Logo/Brand */}
<div className="flex items-center space-x-3"> <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"> <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> <span className="text-white font-bold text-sm">VC</span>
@@ -189,6 +95,7 @@ function App() {
</h1> </h1>
</div> </div>
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-1"> <div className="hidden md:flex items-center space-x-1">
<Link <Link
to="/" to="/"
@@ -208,7 +115,7 @@ function App() {
> >
Drivers Drivers
</Link> </Link>
{userRole === 'administrator' && ( {(user.role === 'administrator' || user.role === 'coordinator') && (
<Link <Link
to="/admin" 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" 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 Admin
</Link> </Link>
)} )}
{userRole === 'administrator' && ( {user.role === 'administrator' && (
<Link <Link
to="/users" 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" 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> </div>
{/* User Menu */}
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="hidden sm:flex items-center space-x-3"> <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"> <div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center">
{user.picture ? (
<img src={user.picture} alt={displayName} className="w-8 h-8 object-cover" />
) : (
<span className="text-white text-xs font-medium"> <span className="text-white text-xs font-medium">
{displayInitial} {user.name.charAt(0).toUpperCase()}
</span> </span>
)}
</div> </div>
<div className="text-sm"> <div className="text-sm">
<div className="font-medium text-slate-900">{displayName}</div> <div className="font-medium text-slate-900">{user.name}</div>
<div className="text-slate-500 capitalize">{userRole}</div> <div className="text-slate-500 capitalize">{user.role}</div>
</div> </div>
</div> </div>
<button <button
@@ -253,6 +157,7 @@ function App() {
</div> </div>
</nav> </nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-6 lg:px-8 py-8">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />

View File

@@ -94,101 +94,6 @@
margin: 0; 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 { .login-footer {
border-top: 1px solid #eee; border-top: 1px solid #eee;
padding-top: 20px; padding-top: 20px;

View File

@@ -3,15 +3,15 @@ import { apiCall } from '../config/api';
import './Login.css'; import './Login.css';
interface LoginProps { interface LoginProps {
onLogin: () => void; onLogin: (user: any) => void;
errorMessage?: string | null | undefined;
} }
const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => { const Login: React.FC<LoginProps> = ({ onLogin }) => {
const [setupStatus, setSetupStatus] = useState<any>(null); const [setupStatus, setSetupStatus] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
// Check system setup status
apiCall('/auth/setup') apiCall('/auth/setup')
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
@@ -22,7 +22,82 @@ const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
console.error('Error checking setup status:', error); console.error('Error checking setup status:', error);
setLoading(false); 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) { if (loading) {
return ( return (
@@ -45,38 +120,55 @@ const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
{!setupStatus?.firstAdminCreated && ( {!setupStatus?.firstAdminCreated && (
<div className="setup-notice"> <div className="setup-notice">
<h3>🚀 First Time Setup</h3> <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>
)} )}
<div className="login-content"> <div className="login-content">
<button <button
className="google-login-btn" className="google-login-btn"
onClick={onLogin} onClick={handleGoogleLogin}
disabled={false}
> >
<svg className="google-icon" viewBox="0 0 24 24"> <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> </svg>
Continue with Auth0 Continue with Google
</button> </button>
<div className="login-info"> <div className="login-info">
<p> <p>
{setupStatus?.authProvider === 'auth0' {setupStatus?.firstAdminCreated
? 'Sign in with your organisation account. We use Auth0 for secure authentication.' ? "Sign in with your Google account to access the VIP Coordinator."
: 'Authentication service is being configured. Please try again later.'} : "Sign in with Google to set up your administrator account."
}
</p> </p>
</div> </div>
{errorMessage && ( {setupStatus && !setupStatus.setupCompleted && (
<div className="dev-login-error" style={{ marginTop: '1rem' }}> <div style={{
{errorMessage} 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> </div>
<div className="login-footer"> <div className="login-footer">
<p>Secure authentication powered by Auth0</p> <p>Secure authentication powered by Google OAuth</p>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,49 +1,4 @@
@tailwind base; @import "tailwindcss";
@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;
}
}
/* Custom base styles */ /* Custom base styles */
@layer base { @layer base {
@@ -92,117 +47,304 @@
@layer components { @layer components {
/* Modern Button Styles */ /* Modern Button Styles */
.btn { .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 { .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 { .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 { .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 { .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 */ /* Modern Card Styles */
.card { .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 */ /* Modern Form Styles */
.form-group { .form-group {
@apply mb-6; margin-bottom: 1.5rem;
} }
.form-label { .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 { .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 { .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 { .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 { .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 { .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 Styles */
.modal-overlay { .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 { .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 { .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 { .modal-body {
@apply p-8; padding: 2rem;
} }
.modal-footer { .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 */
.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 Sections */
.form-section { .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 { .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 { .form-section-title {
@apply text-lg font-bold text-slate-800; font-size: 1.125rem;
font-weight: 700;
color: #1e293b;
} }
/* Radio Group */ /* Radio Group */
.radio-group { .radio-group {
@apply flex gap-6 mt-3; display: flex;
gap: 1.5rem;
margin-top: 0.75rem;
} }
.radio-option { .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 { .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 Group */
.checkbox-option { .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 { .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 React from 'react'
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client'
import { Auth0Provider } from '@auth0/auth0-react'; import App from './App.tsx'
import App from './App.tsx'; import './index.css'
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;
}
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<Auth0Provider
domain={domain}
clientId={clientId}
authorizationParams={authorizationParams}
cacheLocation="localstorage"
useRefreshTokens={true}
>
<App /> <App />
</Auth0Provider> </React.StrictMode>,
</React.StrictMode> )
);

View File

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

View File

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

View File

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