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:
29
.env.prod
Normal file
29
.env.prod
Normal 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
30
.env.production
Normal 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
|
||||
173
DOCUMENTATION_CLEANUP_SUMMARY.md
Normal file
173
DOCUMENTATION_CLEANUP_SUMMARY.md
Normal 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.
|
||||
299
README.md
299
README.md
@@ -1,155 +1,234 @@
|
||||
# VIP Coordinator Dashboard
|
||||
# VIP Coordinator
|
||||
|
||||
A comprehensive web application for managing VIP logistics, driver assignments, and real-time tracking during events.
|
||||
A comprehensive web application for managing VIP logistics, driver assignments, and real-time tracking with Google OAuth authentication and role-based access control.
|
||||
|
||||
## Features
|
||||
## ✨ Features
|
||||
|
||||
- **VIP Management**: Create, edit, and manage VIP profiles with flight information and schedules
|
||||
- **Driver Coordination**: Assign and track drivers with real-time location updates
|
||||
- **Flight Tracking**: Monitor flight status and arrival times
|
||||
- **Schedule Management**: Organize VIP itineraries and driver assignments
|
||||
- **Real-time Dashboard**: Overview of all active VIPs and available drivers
|
||||
### 🔐 Authentication & User Management
|
||||
- **Google OAuth Integration**: Secure login with Google accounts
|
||||
- **Role-Based Access Control**: Administrator, Coordinator, and Driver roles
|
||||
- **User Approval System**: Admin approval required for new users
|
||||
- **JWT-Based Authentication**: Stateless, secure token system
|
||||
|
||||
## Tech Stack
|
||||
### 👥 VIP Management
|
||||
- **Complete VIP Profiles**: Name, organization, department, transport details
|
||||
- **Multi-Flight Support**: Handle complex itineraries with multiple flights
|
||||
- **Department Organization**: Office of Development and Admin departments
|
||||
- **Schedule Management**: Event scheduling with conflict detection
|
||||
- **Real-time Flight Tracking**: Automatic flight status updates
|
||||
|
||||
### 🚗 Driver Coordination
|
||||
- **Driver Management**: Create and manage driver profiles
|
||||
- **Availability Checking**: Real-time conflict detection
|
||||
- **Schedule Assignment**: Assign drivers to VIP events
|
||||
- **Department-Based Organization**: Organize drivers by department
|
||||
|
||||
### ✈️ Flight Integration
|
||||
- **Real-time Flight Data**: Integration with AviationStack API
|
||||
- **Automatic Tracking**: Scheduled flight status updates
|
||||
- **Multi-Flight Support**: Handle complex travel itineraries
|
||||
- **Flight Validation**: Verify flight numbers and dates
|
||||
|
||||
### 📊 Advanced Features
|
||||
- **Interactive API Documentation**: Swagger UI with "Try it out" functionality
|
||||
- **Schedule Validation**: Prevent conflicts and overlapping assignments
|
||||
- **Comprehensive Logging**: Detailed system activity tracking
|
||||
- **Docker Containerization**: Easy deployment and development
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Backend
|
||||
- Node.js with Express.js
|
||||
- TypeScript for type safety
|
||||
- PostgreSQL database
|
||||
- Redis for caching and real-time updates
|
||||
- Docker containerization
|
||||
- **Node.js + Express.js**: RESTful API server
|
||||
- **TypeScript**: Full type safety
|
||||
- **PostgreSQL**: Persistent data storage with automatic schema management
|
||||
- **Redis**: Caching and real-time updates
|
||||
- **JWT Authentication**: Secure, stateless authentication
|
||||
- **Google OAuth 2.0**: Simple, secure user authentication
|
||||
|
||||
### Frontend
|
||||
- React 18 with TypeScript
|
||||
- Vite for fast development
|
||||
- React Router for navigation
|
||||
- Leaflet for mapping (planned)
|
||||
- Responsive design with CSS Grid/Flexbox
|
||||
- **React 18 + TypeScript**: Modern, type-safe frontend
|
||||
- **Vite**: Lightning-fast development server
|
||||
- **Tailwind CSS v4**: Modern utility-first styling
|
||||
- **React Router**: Client-side routing
|
||||
- **Responsive Design**: Mobile-friendly interface
|
||||
|
||||
## Getting Started
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Node.js 18+ (for local development)
|
||||
- npm or yarn
|
||||
- Google Cloud Console account (for OAuth setup)
|
||||
|
||||
### Quick Start with Docker
|
||||
|
||||
1. Clone the repository and navigate to the project directory:
|
||||
### 1. Start the Application
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd vip-coordinator
|
||||
```
|
||||
|
||||
2. Start the development environment:
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
This will start all services:
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend API: http://localhost:3000
|
||||
- PostgreSQL: localhost:5432
|
||||
- Redis: localhost:6379
|
||||
**Services will be available at:**
|
||||
- 🌐 **Frontend**: http://localhost:5173
|
||||
- 🔌 **Backend API**: http://localhost:3000
|
||||
- 📚 **API Documentation**: http://localhost:3000/api-docs.html
|
||||
- 🏥 **Health Check**: http://localhost:3000/api/health
|
||||
|
||||
### Manual Setup
|
||||
### 2. Configure Google OAuth
|
||||
See [SETUP_GUIDE.md](SETUP_GUIDE.md) for detailed OAuth setup instructions.
|
||||
|
||||
#### Backend Setup
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
### 3. First Login
|
||||
- Visit http://localhost:5173
|
||||
- Click "Continue with Google"
|
||||
- First user becomes system administrator
|
||||
- Subsequent users need admin approval
|
||||
|
||||
#### Frontend Setup
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
## 📚 API Documentation
|
||||
|
||||
## API Endpoints
|
||||
### 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
|
||||
|
||||
### 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
|
||||
### 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
|
||||
|
||||
### 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/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ └── index.ts # Main server file
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── Dockerfile
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── types/ # TypeScript types
|
||||
│ │ ├── App.tsx # Main app component
|
||||
│ │ └── main.tsx # Entry point
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.dev.yml # Development environment
|
||||
├── docker-compose.prod.yml # Production environment
|
||||
├── Makefile # Development commands
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
## 🛠️ Development
|
||||
|
||||
### Available Commands
|
||||
```bash
|
||||
# Start development environment
|
||||
make dev
|
||||
|
||||
# Build production images
|
||||
# View logs
|
||||
make logs
|
||||
|
||||
# Stop all services
|
||||
make down
|
||||
|
||||
# Rebuild containers
|
||||
make build
|
||||
|
||||
# Deploy to production
|
||||
make deploy
|
||||
|
||||
# Backend only
|
||||
# Backend development
|
||||
cd backend && npm run dev
|
||||
|
||||
# Frontend only
|
||||
# Frontend development
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
## Planned Features
|
||||
### Project Structure
|
||||
```
|
||||
vip-coordinator/
|
||||
├── backend/ # Node.js API server
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API route handlers
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ ├── config/ # Configuration and auth
|
||||
│ │ └── index.ts # Main server file
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── Dockerfile
|
||||
├── frontend/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── config/ # API configuration
|
||||
│ │ ├── App.tsx # Main app component
|
||||
│ │ └── main.tsx # Entry point
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.dev.yml # Development environment
|
||||
├── docker-compose.prod.yml # Production environment
|
||||
├── Makefile # Development commands
|
||||
├── SETUP_GUIDE.md # Detailed setup instructions
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 🔐 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
|
||||
make dev
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
# Build production images
|
||||
make build
|
||||
|
||||
# Deploy with production configuration
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
See [SETUP_GUIDE.md](SETUP_GUIDE.md) for detailed environment variable configuration.
|
||||
|
||||
## 📋 Current Status
|
||||
|
||||
### ✅ Implemented Features
|
||||
- Google OAuth authentication with JWT
|
||||
- Role-based access control
|
||||
- User approval workflow
|
||||
- VIP management with multi-flight support
|
||||
- Driver management and scheduling
|
||||
- Real-time flight tracking
|
||||
- Schedule conflict detection
|
||||
- Interactive API documentation
|
||||
- Docker containerization
|
||||
- PostgreSQL data persistence
|
||||
|
||||
### 🚧 Planned Features
|
||||
- [ ] Real-time GPS tracking for drivers
|
||||
- [ ] Flight API integration for live updates
|
||||
- [ ] Push notifications for schedule changes
|
||||
- [ ] Google Sheets import/export
|
||||
- [ ] Mobile-responsive driver app
|
||||
- [ ] Advanced scheduling with drag-and-drop
|
||||
- [ ] Reporting and analytics
|
||||
- [ ] Mobile driver application
|
||||
- [ ] Advanced reporting and analytics
|
||||
- [ ] Google Sheets integration
|
||||
- [ ] Multi-tenant support
|
||||
- [ ] Advanced mapping features
|
||||
|
||||
## Contributing
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly
|
||||
5. Submit a pull request
|
||||
2. Create a feature branch: `git checkout -b feature/amazing-feature`
|
||||
3. Make your changes and test thoroughly
|
||||
4. Commit your changes: `git commit -m 'Add amazing feature'`
|
||||
5. Push to the branch: `git push origin feature/amazing-feature`
|
||||
6. Submit a pull request
|
||||
|
||||
## License
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
- 📖 **Documentation**: Check [SETUP_GUIDE.md](SETUP_GUIDE.md) for detailed setup
|
||||
- 🔧 **API Reference**: Visit http://localhost:3000/api-docs.html
|
||||
- 🐛 **Issues**: Report bugs and request features via GitHub issues
|
||||
- 💬 **Discussions**: Use GitHub discussions for questions and ideas
|
||||
|
||||
---
|
||||
|
||||
**VIP Coordinator** - Streamlining VIP logistics with modern web technology.
|
||||
|
||||
314
SETUP_GUIDE.md
Normal file
314
SETUP_GUIDE.md
Normal 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.
|
||||
@@ -1,5 +1,5 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://postgres:password@db:5432/vip_coordinator
|
||||
DATABASE_URL=postgresql://postgres:changeme@db:5432/vip_coordinator
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://redis:6379
|
||||
@@ -8,7 +8,7 @@ REDIS_URL=redis://redis:6379
|
||||
JWT_SECRET=your-super-secure-jwt-secret-key-change-in-production-12345
|
||||
SESSION_SECRET=your-super-secure-session-secret-change-in-production-67890
|
||||
|
||||
# Google OAuth Configuration
|
||||
# Google OAuth Configuration (optional for local development)
|
||||
GOOGLE_CLIENT_ID=308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_REDIRECT_URI=https://api.bsa.madeamess.online/auth/google/callback
|
||||
@@ -21,3 +21,6 @@ AVIATIONSTACK_API_KEY=your-aviationstack-api-key
|
||||
|
||||
# Admin Configuration
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# Port Configuration
|
||||
PORT=3000
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Multi-stage build for development and production
|
||||
FROM node:18-alpine AS base
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,10 +15,7 @@ CMD ["npm", "run", "dev"]
|
||||
|
||||
# Production stage
|
||||
FROM base AS production
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
203
backend/package-lock.json
generated
203
backend/package-lock.json
generated
@@ -13,7 +13,6 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"pg": "^8.11.3",
|
||||
"redis": "^4.6.8",
|
||||
"uuid": "^9.0.0"
|
||||
@@ -80,7 +79,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
@@ -154,6 +152,7 @@
|
||||
"version": "1.19.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
@@ -163,6 +162,7 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -180,6 +180,7 @@
|
||||
"version": "4.17.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz",
|
||||
"integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -191,6 +192,7 @@
|
||||
"version": "4.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
|
||||
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
@@ -201,12 +203,14 @@
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
|
||||
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA=="
|
||||
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
|
||||
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
@@ -216,19 +220,21 @@
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.17.57",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
|
||||
"integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==",
|
||||
"peer": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
@@ -248,17 +254,20 @@
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
|
||||
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
@@ -268,6 +277,7 @@
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
|
||||
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
@@ -1016,15 +1026,6 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
@@ -1064,46 +1065,6 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jwks-rsa": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
|
||||
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/jsonwebtoken": "^9.0.4",
|
||||
"debug": "^4.3.4",
|
||||
"jose": "^4.15.4",
|
||||
"limiter": "^1.1.5",
|
||||
"lru-memoizer": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/jwks-rsa/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jwks-rsa/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
@@ -1114,17 +1075,6 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/limiter": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
@@ -1167,28 +1117,6 @@
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-memoizer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
|
||||
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
@@ -1384,7 +1312,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
|
||||
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.0",
|
||||
"pg-pool": "^3.10.0",
|
||||
@@ -1990,7 +1917,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -2002,7 +1928,8 @@
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
@@ -2119,7 +2046,6 @@
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
@@ -2178,6 +2104,7 @@
|
||||
"version": "1.19.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
@@ -2187,6 +2114,7 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -2204,6 +2132,7 @@
|
||||
"version": "4.17.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz",
|
||||
"integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -2215,6 +2144,7 @@
|
||||
"version": "4.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
|
||||
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
@@ -2225,12 +2155,14 @@
|
||||
"@types/http-errors": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
|
||||
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA=="
|
||||
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/jsonwebtoken": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
|
||||
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
@@ -2239,18 +2171,20 @@
|
||||
"@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "20.17.57",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
|
||||
"integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==",
|
||||
"peer": true,
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
@@ -2269,17 +2203,20 @@
|
||||
"@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/send": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
|
||||
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
@@ -2289,6 +2226,7 @@
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
|
||||
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
@@ -2841,11 +2779,6 @@
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true
|
||||
},
|
||||
"jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="
|
||||
},
|
||||
"jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
@@ -2880,34 +2813,6 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"jwks-rsa": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
|
||||
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
|
||||
"requires": {
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/jsonwebtoken": "^9.0.4",
|
||||
"debug": "^4.3.4",
|
||||
"jose": "^4.15.4",
|
||||
"limiter": "^1.1.5",
|
||||
"lru-memoizer": "^2.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"requires": {
|
||||
"ms": "^2.1.3"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
@@ -2917,16 +2822,6 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"limiter": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||
},
|
||||
"lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
|
||||
},
|
||||
"lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
@@ -2962,23 +2857,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
|
||||
},
|
||||
"lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"requires": {
|
||||
"yallist": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"lru-memoizer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
|
||||
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
|
||||
"requires": {
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "6.0.0"
|
||||
}
|
||||
},
|
||||
"make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
@@ -3113,7 +2991,6 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
|
||||
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"pg-cloudflare": "^1.2.5",
|
||||
"pg-connection-string": "^2.9.0",
|
||||
@@ -3509,13 +3386,13 @@
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"undici-types": {
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||
"dev": true
|
||||
},
|
||||
"unpipe": {
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"dev": "npx tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
@@ -22,7 +22,6 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"pg": "^8.11.3",
|
||||
"redis": "^4.6.8",
|
||||
"uuid": "^9.0.0"
|
||||
@@ -34,7 +33,9 @@
|
||||
"@types/node": "^20.5.0",
|
||||
"@types/pg": "^8.10.2",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.1.6"
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,8 @@ import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const useSSL = process.env.DATABASE_SSL === 'true';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:changeme@localhost:5432/vip_coordinator',
|
||||
ssl: useSSL ? { rejectUnauthorized: false } : false,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
|
||||
@@ -1,178 +1,134 @@
|
||||
import jwt, { JwtHeader, JwtPayload } from 'jsonwebtoken';
|
||||
import jwksClient from 'jwks-rsa';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const auth0Domain = process.env.AUTH0_DOMAIN;
|
||||
const auth0Audience = process.env.AUTH0_AUDIENCE;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
if (!auth0Domain) {
|
||||
console.warn('⚠️ AUTH0_DOMAIN is not set. Authentication routes will reject requests until configured.');
|
||||
export interface User {
|
||||
id: string;
|
||||
google_id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
profile_picture_url?: string;
|
||||
role: 'driver' | 'coordinator' | 'administrator';
|
||||
created_at?: string;
|
||||
last_login?: string;
|
||||
is_active?: boolean;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
if (!auth0Audience) {
|
||||
console.warn('⚠️ AUTH0_AUDIENCE is not set. Authentication routes will reject requests until configured.');
|
||||
export function generateToken(user: User): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
google_id: user.google_id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
profile_picture_url: user.profile_picture_url,
|
||||
role: user.role
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
}
|
||||
|
||||
const jwks = auth0Domain
|
||||
? jwksClient({
|
||||
jwksUri: `https://${auth0Domain}/.well-known/jwks.json`,
|
||||
cache: true,
|
||||
cacheMaxEntries: 5,
|
||||
cacheMaxAge: 10 * 60 * 1000
|
||||
})
|
||||
: null;
|
||||
|
||||
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const profileCache = new Map<string, { profile: Auth0UserProfile; expiresAt: number }>();
|
||||
const inflightProfileRequests = new Map<string, Promise<Auth0UserProfile>>();
|
||||
|
||||
export interface Auth0UserProfile {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
nickname?: string;
|
||||
picture?: string;
|
||||
[key: string]: unknown;
|
||||
export function verifyToken(token: string): User | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
return {
|
||||
id: decoded.id,
|
||||
google_id: decoded.google_id,
|
||||
email: decoded.email,
|
||||
name: decoded.name,
|
||||
profile_picture_url: decoded.profile_picture_url,
|
||||
role: decoded.role
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifiedAccessToken extends JwtPayload {
|
||||
sub: string;
|
||||
azp?: string;
|
||||
scope?: string;
|
||||
// Simple Google OAuth2 client using fetch
|
||||
export async function verifyGoogleToken(googleToken: string): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(`https://www.googleapis.com/oauth2/v1/userinfo?access_token=${googleToken}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid Google token');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error verifying Google token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSigningKey(header: JwtHeader): Promise<string> {
|
||||
if (!jwks) {
|
||||
throw new Error('Auth0 JWKS client not initialised');
|
||||
// Get Google OAuth2 URL
|
||||
export function getGoogleAuthUrl(): string {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/auth/google/callback';
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error('GOOGLE_CLIENT_ID not configured');
|
||||
}
|
||||
|
||||
if (!header.kid) {
|
||||
throw new Error('Token signing key id (kid) is missing');
|
||||
}
|
||||
|
||||
const signingKey = await new Promise<jwksClient.SigningKey>((resolve, reject) => {
|
||||
jwks.getSigningKey(header.kid as string, (err, key) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
if (!key) {
|
||||
return reject(new Error('Signing key not found'));
|
||||
}
|
||||
resolve(key);
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent'
|
||||
});
|
||||
|
||||
const publicKey =
|
||||
typeof signingKey.getPublicKey === 'function'
|
||||
? signingKey.getPublicKey()
|
||||
: (signingKey as any).publicKey || (signingKey as any).rsaPublicKey;
|
||||
|
||||
if (!publicKey) {
|
||||
throw new Error('Unable to derive signing key');
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<VerifiedAccessToken> {
|
||||
if (!auth0Domain || !auth0Audience) {
|
||||
throw new Error('Auth0 configuration is incomplete');
|
||||
// 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');
|
||||
}
|
||||
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded || typeof decoded === 'string') {
|
||||
throw new Error('Invalid JWT');
|
||||
}
|
||||
|
||||
const signingKey = await getSigningKey(decoded.header);
|
||||
|
||||
return jwt.verify(token, signingKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience: auth0Audience,
|
||||
issuer: `https://${auth0Domain}/`
|
||||
}) as VerifiedAccessToken;
|
||||
}
|
||||
|
||||
export async function fetchAuth0UserProfile(accessToken: string, cacheKey: string, expiresAt?: number): Promise<Auth0UserProfile> {
|
||||
if (!auth0Domain) {
|
||||
throw new Error('Auth0 configuration is incomplete');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cached = profileCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.profile;
|
||||
}
|
||||
|
||||
const ttl = expiresAt ? Math.max(0, expiresAt * 1000 - now) : PROFILE_CACHE_TTL_MS;
|
||||
if (inflightProfileRequests.has(cacheKey)) {
|
||||
return inflightProfileRequests.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const response = await fetch(`https://${auth0Domain}/userinfo`, {
|
||||
try {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Auth0 user profile (${response.status})`);
|
||||
throw new Error('Failed to exchange code for tokens');
|
||||
}
|
||||
|
||||
const profile = (await response.json()) as Auth0UserProfile;
|
||||
profileCache.set(cacheKey, { profile, expiresAt: now + ttl });
|
||||
inflightProfileRequests.delete(cacheKey);
|
||||
return profile;
|
||||
})().catch(error => {
|
||||
inflightProfileRequests.delete(cacheKey);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error exchanging code for tokens:', error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
inflightProfileRequests.set(cacheKey, fetchPromise);
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
export function clearAuth0ProfileCache(cacheKey?: string) {
|
||||
if (cacheKey) {
|
||||
profileCache.delete(cacheKey);
|
||||
inflightProfileRequests.delete(cacheKey);
|
||||
} else {
|
||||
profileCache.clear();
|
||||
inflightProfileRequests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedProfile(cacheKey: string): Auth0UserProfile | undefined {
|
||||
const cached = profileCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.profile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function cacheAuth0Profile(cacheKey: string, profile: Auth0UserProfile, expiresAt?: number) {
|
||||
const ttl = expiresAt ? Math.max(0, expiresAt * 1000 - Date.now()) : PROFILE_CACHE_TTL_MS;
|
||||
profileCache.set(cacheKey, { profile, expiresAt: Date.now() + ttl });
|
||||
}
|
||||
|
||||
export async function fetchFreshAuth0Profile(accessToken: string): Promise<Auth0UserProfile> {
|
||||
if (!auth0Domain) {
|
||||
throw new Error('Auth0 configuration is incomplete');
|
||||
}
|
||||
|
||||
const response = await fetch(`https://${auth0Domain}/userinfo`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
// Get user info from Google
|
||||
export async function getGoogleUserInfo(accessToken: string): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(`https://www.googleapis.com/oauth2/v2/userinfo?access_token=${accessToken}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get user info');
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Auth0 user profile (${response.status})`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error getting Google user info:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (await response.json()) as Auth0UserProfile;
|
||||
}
|
||||
|
||||
export function isAuth0Configured(): boolean {
|
||||
return Boolean(auth0Domain && auth0Audience);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ app.use(cors({
|
||||
origin: [
|
||||
process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
'https://bsa.madeamess.online:5173',
|
||||
'https://bsa.madeamess.online',
|
||||
'https://api.bsa.madeamess.online',
|
||||
'http://bsa.madeamess.online:5173'
|
||||
],
|
||||
credentials: true
|
||||
@@ -46,6 +48,9 @@ app.get('/api/health', (req: Request, res: Response) => {
|
||||
|
||||
// Data is now persisted using dataService - no more in-memory storage!
|
||||
|
||||
// Simple admin password (in production, use proper auth)
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
// Initialize flight tracking scheduler
|
||||
const flightTracker = new FlightTrackingScheduler(flightService);
|
||||
|
||||
@@ -606,21 +611,35 @@ app.get('/api/drivers/:driverId/schedule', requireAuth, async (req: Request, res
|
||||
});
|
||||
|
||||
// Admin routes
|
||||
app.get('/api/admin/settings', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
app.post('/api/admin/authenticate', (req: Request, res: Response) => {
|
||||
const { password } = req.body;
|
||||
|
||||
if (password === ADMIN_PASSWORD) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid password' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/settings', async (req: Request, res: Response) => {
|
||||
const adminAuth = req.headers['admin-auth'];
|
||||
|
||||
if (adminAuth !== 'true') {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
const adminSettings = await enhancedDataService.getAdminSettings();
|
||||
const apiKeys = adminSettings.apiKeys || {};
|
||||
|
||||
// Return settings but mask API keys for display only
|
||||
// IMPORTANT: Don't return the actual keys, just indicate they exist
|
||||
const maskedSettings = {
|
||||
apiKeys: {
|
||||
aviationStackKey: apiKeys.aviationStackKey ? '***' + apiKeys.aviationStackKey.slice(-4) : '',
|
||||
googleMapsKey: apiKeys.googleMapsKey ? '***' + apiKeys.googleMapsKey.slice(-4) : '',
|
||||
twilioKey: apiKeys.twilioKey ? '***' + apiKeys.twilioKey.slice(-4) : '',
|
||||
auth0Domain: apiKeys.auth0Domain ? '***' + apiKeys.auth0Domain.slice(-4) : '',
|
||||
auth0ClientId: apiKeys.auth0ClientId ? '***' + apiKeys.auth0ClientId.slice(-4) : '',
|
||||
auth0ClientSecret: apiKeys.auth0ClientSecret ? '***' + apiKeys.auth0ClientSecret.slice(-4) : ''
|
||||
aviationStackKey: adminSettings.apiKeys.aviationStackKey ? '***' + adminSettings.apiKeys.aviationStackKey.slice(-4) : '',
|
||||
googleMapsKey: adminSettings.apiKeys.googleMapsKey ? '***' + adminSettings.apiKeys.googleMapsKey.slice(-4) : '',
|
||||
twilioKey: adminSettings.apiKeys.twilioKey ? '***' + adminSettings.apiKeys.twilioKey.slice(-4) : '',
|
||||
googleClientId: adminSettings.apiKeys.googleClientId ? '***' + adminSettings.apiKeys.googleClientId.slice(-4) : '',
|
||||
googleClientSecret: adminSettings.apiKeys.googleClientSecret ? '***' + adminSettings.apiKeys.googleClientSecret.slice(-4) : ''
|
||||
},
|
||||
systemSettings: adminSettings.systemSettings
|
||||
};
|
||||
@@ -631,11 +650,16 @@ app.get('/api/admin/settings', requireAuth, requireRole(['administrator']), asyn
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/settings', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
app.post('/api/admin/settings', async (req: Request, res: Response) => {
|
||||
const adminAuth = req.headers['admin-auth'];
|
||||
|
||||
if (adminAuth !== 'true') {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { apiKeys, systemSettings } = req.body;
|
||||
const currentSettings = await enhancedDataService.getAdminSettings();
|
||||
currentSettings.apiKeys = currentSettings.apiKeys || {};
|
||||
|
||||
// Update API keys (only if provided and not masked)
|
||||
if (apiKeys) {
|
||||
@@ -650,17 +674,15 @@ app.post('/api/admin/settings', requireAuth, requireRole(['administrator']), asy
|
||||
if (apiKeys.twilioKey && !apiKeys.twilioKey.startsWith('***')) {
|
||||
currentSettings.apiKeys.twilioKey = apiKeys.twilioKey;
|
||||
}
|
||||
if (apiKeys.auth0Domain && !apiKeys.auth0Domain.startsWith('***')) {
|
||||
currentSettings.apiKeys.auth0Domain = apiKeys.auth0Domain;
|
||||
process.env.AUTH0_DOMAIN = apiKeys.auth0Domain;
|
||||
if (apiKeys.googleClientId && !apiKeys.googleClientId.startsWith('***')) {
|
||||
currentSettings.apiKeys.googleClientId = apiKeys.googleClientId;
|
||||
// Update the environment variable for Google OAuth
|
||||
process.env.GOOGLE_CLIENT_ID = apiKeys.googleClientId;
|
||||
}
|
||||
if (apiKeys.auth0ClientId && !apiKeys.auth0ClientId.startsWith('***')) {
|
||||
currentSettings.apiKeys.auth0ClientId = apiKeys.auth0ClientId;
|
||||
process.env.AUTH0_CLIENT_ID = apiKeys.auth0ClientId;
|
||||
}
|
||||
if (apiKeys.auth0ClientSecret && !apiKeys.auth0ClientSecret.startsWith('***')) {
|
||||
currentSettings.apiKeys.auth0ClientSecret = apiKeys.auth0ClientSecret;
|
||||
process.env.AUTH0_CLIENT_SECRET = apiKeys.auth0ClientSecret;
|
||||
if (apiKeys.googleClientSecret && !apiKeys.googleClientSecret.startsWith('***')) {
|
||||
currentSettings.apiKeys.googleClientSecret = apiKeys.googleClientSecret;
|
||||
// Update the environment variable for Google OAuth
|
||||
process.env.GOOGLE_CLIENT_SECRET = apiKeys.googleClientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +700,13 @@ app.post('/api/admin/settings', requireAuth, requireRole(['administrator']), asy
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/test-api/:apiType', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
app.post('/api/admin/test-api/:apiType', async (req: Request, res: Response) => {
|
||||
const adminAuth = req.headers['admin-auth'];
|
||||
|
||||
if (adminAuth !== 'true') {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { apiType } = req.params;
|
||||
const { apiKey } = req.body;
|
||||
|
||||
@@ -727,6 +755,7 @@ async function startServer() {
|
||||
// Start the server
|
||||
app.listen(port, () => {
|
||||
console.log(`🚀 Server is running on port ${port}`);
|
||||
console.log(`🔐 Admin password: ${ADMIN_PASSWORD}`);
|
||||
console.log(`📊 Admin dashboard: http://localhost:${port === 3000 ? 5173 : port}/admin`);
|
||||
console.log(`🏥 Health check: http://localhost:${port}/api/health`);
|
||||
console.log(`📚 API docs: http://localhost:${port}/api-docs.html`);
|
||||
|
||||
@@ -1,176 +1,64 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
fetchAuth0UserProfile,
|
||||
isAuth0Configured,
|
||||
verifyAccessToken,
|
||||
VerifiedAccessToken,
|
||||
Auth0UserProfile,
|
||||
getCachedProfile,
|
||||
cacheAuth0Profile
|
||||
import {
|
||||
generateToken,
|
||||
verifyToken,
|
||||
getGoogleAuthUrl,
|
||||
exchangeCodeForTokens,
|
||||
getGoogleUserInfo,
|
||||
User
|
||||
} from '../config/simpleAuth';
|
||||
import databaseService from '../services/databaseService';
|
||||
|
||||
type AuthedRequest = Request & {
|
||||
auth?: {
|
||||
token: string;
|
||||
claims: VerifiedAccessToken;
|
||||
profile?: Auth0UserProfile | null;
|
||||
};
|
||||
user?: any;
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function mapUserForResponse(user: any) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.profile_picture_url,
|
||||
role: user.role,
|
||||
approval_status: user.approval_status,
|
||||
created_at: user.created_at,
|
||||
last_login: user.last_login,
|
||||
provider: 'auth0'
|
||||
};
|
||||
}
|
||||
|
||||
async function syncUserWithDatabase(claims: VerifiedAccessToken, token: string): Promise<{ user: any; profile: Auth0UserProfile | null }> {
|
||||
const auth0Id = claims.sub;
|
||||
const initialAdminEmails = (process.env.INITIAL_ADMIN_EMAILS || '')
|
||||
.split(',')
|
||||
.map(email => email.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
let profile: Auth0UserProfile | null = null;
|
||||
let user = await databaseService.getUserById(auth0Id);
|
||||
|
||||
if (user) {
|
||||
const updated = await databaseService.updateUserLastSignIn(user.email);
|
||||
user = updated || user;
|
||||
|
||||
const isSeedAdmin = initialAdminEmails.includes((user.email || '').toLowerCase());
|
||||
if (isSeedAdmin && user.role !== 'administrator') {
|
||||
user = await databaseService.updateUserRole(user.email, 'administrator');
|
||||
}
|
||||
if (isSeedAdmin && user.approval_status !== 'approved') {
|
||||
user = await databaseService.updateUserApprovalStatus(user.email, 'approved');
|
||||
}
|
||||
|
||||
return { user, profile };
|
||||
}
|
||||
|
||||
const cacheKey = auth0Id;
|
||||
profile = getCachedProfile(cacheKey) || null;
|
||||
|
||||
if (!profile) {
|
||||
profile = await fetchAuth0UserProfile(token, cacheKey, claims.exp);
|
||||
cacheAuth0Profile(cacheKey, profile, claims.exp);
|
||||
}
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Auth0 profile did not include an email address');
|
||||
}
|
||||
|
||||
const existingByEmail = await databaseService.getUserByEmail(profile.email);
|
||||
|
||||
if (existingByEmail && existingByEmail.id !== auth0Id) {
|
||||
await databaseService.migrateUserId(existingByEmail.id, auth0Id);
|
||||
user = await databaseService.getUserById(auth0Id);
|
||||
} else if (existingByEmail) {
|
||||
user = existingByEmail;
|
||||
}
|
||||
|
||||
const displayName = profile.name || profile.nickname || profile.email;
|
||||
const picture = typeof profile.picture === 'string' ? profile.picture : undefined;
|
||||
const isSeedAdmin = initialAdminEmails.includes(profile.email.toLowerCase());
|
||||
|
||||
if (!user) {
|
||||
const approvedUserCount = await databaseService.getApprovedUserCount();
|
||||
const role = isSeedAdmin
|
||||
? 'administrator'
|
||||
: approvedUserCount === 0
|
||||
? 'administrator'
|
||||
: 'coordinator';
|
||||
|
||||
user = await databaseService.createUser({
|
||||
id: auth0Id,
|
||||
google_id: auth0Id,
|
||||
email: profile.email,
|
||||
name: displayName,
|
||||
profile_picture_url: picture,
|
||||
role
|
||||
});
|
||||
|
||||
if (role === 'administrator') {
|
||||
user = await databaseService.updateUserApprovalStatus(profile.email, 'approved');
|
||||
}
|
||||
} else {
|
||||
const updated = await databaseService.updateUserLastSignIn(user.email);
|
||||
user = updated || user;
|
||||
|
||||
if (isSeedAdmin && user.role !== 'administrator') {
|
||||
user = await databaseService.updateUserRole(user.email, 'administrator');
|
||||
}
|
||||
if (isSeedAdmin && user.approval_status !== 'approved') {
|
||||
user = await databaseService.updateUserApprovalStatus(user.email, 'approved');
|
||||
}
|
||||
}
|
||||
|
||||
return { user, profile };
|
||||
}
|
||||
|
||||
export async function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||
// Middleware to check authentication
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const user = verifyToken(token);
|
||||
|
||||
try {
|
||||
const claims = await verifyAccessToken(token);
|
||||
const { user, profile } = await syncUserWithDatabase(claims, token);
|
||||
|
||||
req.auth = { token, claims, profile };
|
||||
req.user = user;
|
||||
|
||||
if (user.approval_status !== 'approved') {
|
||||
return res.status(403).json({
|
||||
error: 'pending_approval',
|
||||
message: 'Your account is pending administrator approval.',
|
||||
user: mapUserForResponse(user)
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error: any) {
|
||||
console.error('Auth0 token verification failed:', error);
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
(req as any).user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
// Middleware to check role
|
||||
export function requireRole(roles: string[]) {
|
||||
return (req: AuthedRequest, res: Response, next: NextFunction) => {
|
||||
const user = req.user;
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = (req as any).user;
|
||||
|
||||
if (!user || !roles.includes(user.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/setup', async (_req: Request, res: Response) => {
|
||||
// Get current user
|
||||
router.get('/me', requireAuth, (req: Request, res: Response) => {
|
||||
res.json((req as any).user);
|
||||
});
|
||||
|
||||
// Setup status endpoint (required by frontend)
|
||||
router.get('/setup', async (req: Request, res: Response) => {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
try {
|
||||
const userCount = await databaseService.getUserCount();
|
||||
res.json({
|
||||
setupCompleted: isAuth0Configured(),
|
||||
setupCompleted: !!(clientId && clientSecret && clientId !== 'your-google-client-id-from-console'),
|
||||
firstAdminCreated: userCount > 0,
|
||||
oauthConfigured: isAuth0Configured(),
|
||||
authProvider: 'auth0'
|
||||
oauthConfigured: !!(clientId && clientSecret)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking setup status:', error);
|
||||
@@ -178,35 +66,206 @@ router.get('/setup', async (_req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||
res.json({
|
||||
user: mapUserForResponse(req.user),
|
||||
auth0: {
|
||||
sub: req.auth?.claims.sub,
|
||||
scope: req.auth?.claims.scope
|
||||
}
|
||||
});
|
||||
// Start Google OAuth flow
|
||||
router.get('/google', (req: Request, res: Response) => {
|
||||
try {
|
||||
const authUrl = getGoogleAuthUrl();
|
||||
res.redirect(authUrl);
|
||||
} catch (error) {
|
||||
console.error('Error starting Google OAuth:', error);
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
res.redirect(`${frontendUrl}?error=oauth_not_configured`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', (_req: Request, res: Response) => {
|
||||
// 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
|
||||
});
|
||||
|
||||
// Auto-approve first admin, others need approval
|
||||
if (approvedUserCount === 0) {
|
||||
await databaseService.updateUserApprovalStatus(googleUser.email, 'approved');
|
||||
user.approval_status = 'approved';
|
||||
}
|
||||
} else {
|
||||
// Update last sign in
|
||||
await databaseService.updateUserLastSignIn(googleUser.email);
|
||||
console.log(`✅ User logged in: ${user.name} (${user.email})`);
|
||||
}
|
||||
|
||||
// Check if user is approved
|
||||
if (user.approval_status !== 'approved') {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
return res.redirect(`${frontendUrl}?error=pending_approval&message=Your account is pending administrator approval`);
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Redirect to frontend with token
|
||||
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in OAuth callback:', error);
|
||||
res.redirect(`${frontendUrl}?error=oauth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
// Exchange OAuth code for JWT token (alternative endpoint for frontend)
|
||||
router.post('/google/exchange', async (req: Request, res: Response) => {
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code);
|
||||
|
||||
// Get user info
|
||||
const googleUser = await getGoogleUserInfo(tokens.access_token);
|
||||
|
||||
// Check if user exists or create new user
|
||||
let user = await databaseService.getUserByEmail(googleUser.email);
|
||||
|
||||
if (!user) {
|
||||
// Determine role - first user becomes admin
|
||||
const userCount = await databaseService.getUserCount();
|
||||
const role = userCount === 0 ? 'administrator' : 'coordinator';
|
||||
|
||||
user = await databaseService.createUser({
|
||||
id: googleUser.id,
|
||||
google_id: googleUser.id,
|
||||
email: googleUser.email,
|
||||
name: googleUser.name,
|
||||
profile_picture_url: googleUser.picture,
|
||||
role
|
||||
});
|
||||
} else {
|
||||
// Update last sign in
|
||||
await databaseService.updateUserLastSignIn(googleUser.email);
|
||||
console.log(`✅ User logged in: ${user.name} (${user.email})`);
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Return token to frontend
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.profile_picture_url,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in OAuth exchange:', error);
|
||||
res.status(500).json({ error: 'Failed to exchange authorization code' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get OAuth URL for frontend to redirect to
|
||||
router.get('/google/url', (req: Request, res: Response) => {
|
||||
try {
|
||||
const authUrl = getGoogleAuthUrl();
|
||||
res.json({ url: authUrl });
|
||||
} catch (error) {
|
||||
console.error('Error getting Google OAuth URL:', error);
|
||||
res.status(500).json({ error: 'OAuth not configured' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req: Request, res: Response) => {
|
||||
// With JWT, logout is handled client-side by removing the token
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
router.get('/status', requireAuth, (req: AuthedRequest, res: Response) => {
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: mapUserForResponse(req.user)
|
||||
// Get auth status
|
||||
router.get('/status', (req: Request, res: Response) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const user = verifyToken(token);
|
||||
|
||||
if (!user) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.profile_picture_url,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// USER MANAGEMENT ENDPOINTS
|
||||
|
||||
// List all users (admin only)
|
||||
router.get('/users', requireAuth, requireRole(['administrator']), async (_req: Request, res: Response) => {
|
||||
router.get('/users', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const users = await databaseService.getAllUsers();
|
||||
|
||||
res.json(users.map(mapUserForResponse));
|
||||
|
||||
const userList = users.map(user => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.profile_picture_url,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
last_login: user.last_login,
|
||||
provider: 'google'
|
||||
}));
|
||||
|
||||
res.json(userList);
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch users' });
|
||||
@@ -230,7 +289,12 @@ router.patch('/users/:email/role', requireAuth, requireRole(['administrator']),
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: mapUserForResponse(user)
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user role:', error);
|
||||
@@ -239,9 +303,9 @@ router.patch('/users/:email/role', requireAuth, requireRole(['administrator']),
|
||||
});
|
||||
|
||||
// Delete user (admin only)
|
||||
router.delete('/users/:email', requireAuth, requireRole(['administrator']), async (req: AuthedRequest, res: Response) => {
|
||||
router.delete('/users/:email', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => {
|
||||
const { email } = req.params;
|
||||
const currentUser = req.user;
|
||||
const currentUser = (req as any).user;
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (email === currentUser.email) {
|
||||
@@ -272,7 +336,17 @@ router.get('/users/:email', requireAuth, requireRole(['administrator']), async (
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(mapUserForResponse(user));
|
||||
res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.profile_picture_url,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
last_login: user.last_login,
|
||||
provider: 'google',
|
||||
approval_status: user.approval_status
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user' });
|
||||
@@ -286,7 +360,16 @@ router.get('/users/pending/list', requireAuth, requireRole(['administrator']), a
|
||||
try {
|
||||
const pendingUsers = await databaseService.getPendingUsers();
|
||||
|
||||
const userList = pendingUsers.map(mapUserForResponse);
|
||||
const userList = pendingUsers.map(user => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.profile_picture_url,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
provider: 'google',
|
||||
approval_status: user.approval_status
|
||||
}));
|
||||
|
||||
res.json(userList);
|
||||
} catch (error) {
|
||||
@@ -313,7 +396,13 @@ router.patch('/users/:email/approval', requireAuth, requireRole(['administrator'
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${status} successfully`,
|
||||
user: mapUserForResponse(user)
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
approval_status: user.approval_status
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user approval:', error);
|
||||
|
||||
@@ -6,10 +6,9 @@ class DatabaseService {
|
||||
private redis: RedisClientType;
|
||||
|
||||
constructor() {
|
||||
const useSSL = process.env.DATABASE_SSL === 'true';
|
||||
this.pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: useSSL ? { rejectUnauthorized: false } : false
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
// Initialize Redis connection
|
||||
@@ -98,38 +97,6 @@ class DatabaseService {
|
||||
ADD COLUMN IF NOT EXISTS approval_status VARCHAR(20) DEFAULT 'pending' CHECK (approval_status IN ('pending', 'approved', 'denied'))
|
||||
`);
|
||||
|
||||
// Admin settings storage table
|
||||
await this.query(`
|
||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
setting_key VARCHAR(255) UNIQUE NOT NULL,
|
||||
setting_value TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await this.query(`
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
`);
|
||||
|
||||
await this.query(`
|
||||
DROP TRIGGER IF EXISTS update_admin_settings_updated_at ON admin_settings
|
||||
`);
|
||||
|
||||
await this.query(`
|
||||
CREATE TRIGGER update_admin_settings_updated_at
|
||||
BEFORE UPDATE ON admin_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column()
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
await this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id)
|
||||
@@ -191,31 +158,6 @@ class DatabaseService {
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async migrateUserId(oldId: string, newId: string): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
await client.query(
|
||||
'UPDATE drivers SET user_id = $2 WHERE user_id = $1',
|
||||
[oldId, newId]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
'UPDATE users SET id = $2, google_id = $2 WHERE id = $1',
|
||||
[oldId, newId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<any[]> {
|
||||
const query = 'SELECT * FROM users ORDER BY created_at ASC';
|
||||
const result = await this.query(query);
|
||||
@@ -313,29 +255,52 @@ class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// VIP schema (flights, drivers, schedules)
|
||||
// VIP table initialization using the correct schema
|
||||
async initializeVipTables(): Promise<void> {
|
||||
try {
|
||||
// Check if VIPs table exists and has the correct schema
|
||||
const tableExists = await this.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'vips'
|
||||
)
|
||||
`);
|
||||
|
||||
if (tableExists.rows[0].exists) {
|
||||
// Check if the table has the correct columns
|
||||
const columnCheck = await this.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'vips'
|
||||
AND column_name = 'organization'
|
||||
`);
|
||||
|
||||
if (columnCheck.rows.length === 0) {
|
||||
console.log('🔄 Migrating VIPs table to new schema...');
|
||||
// Drop the old table and recreate with correct schema
|
||||
await this.query(`DROP TABLE IF EXISTS vips CASCADE`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create VIPs table with correct schema matching enhancedDataService expectations
|
||||
await this.query(`
|
||||
CREATE TABLE IF NOT EXISTS vips (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
organization VARCHAR(255) NOT NULL,
|
||||
department VARCHAR(255) DEFAULT 'Office of Development',
|
||||
transport_mode VARCHAR(50) NOT NULL CHECK (transport_mode IN ('flight', 'self-driving')),
|
||||
expected_arrival TIMESTAMP,
|
||||
needs_airport_pickup BOOLEAN DEFAULT false,
|
||||
needs_venue_transport BOOLEAN DEFAULT true,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await this.query(`
|
||||
ALTER TABLE vips
|
||||
ADD COLUMN IF NOT EXISTS organization VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS department VARCHAR(255) DEFAULT 'Office of Development',
|
||||
ADD COLUMN IF NOT EXISTS transport_mode VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS expected_arrival TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS needs_airport_pickup BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS needs_venue_transport BOOLEAN DEFAULT true
|
||||
`);
|
||||
|
||||
// Create flights table (for VIPs with flight transport)
|
||||
await this.query(`
|
||||
CREATE TABLE IF NOT EXISTS flights (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -355,22 +320,69 @@ class DatabaseService {
|
||||
)
|
||||
`);
|
||||
|
||||
// Check and migrate drivers table
|
||||
const driversTableExists = await this.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'drivers'
|
||||
)
|
||||
`);
|
||||
|
||||
if (driversTableExists.rows[0].exists) {
|
||||
// Check if drivers table has the correct schema (phone column and department column)
|
||||
const driversSchemaCheck = await this.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'drivers'
|
||||
AND column_name IN ('phone', 'department')
|
||||
`);
|
||||
|
||||
if (driversSchemaCheck.rows.length < 2) {
|
||||
console.log('🔄 Migrating drivers table to new schema...');
|
||||
await this.query(`DROP TABLE IF EXISTS drivers CASCADE`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create drivers table with correct schema
|
||||
await this.query(`
|
||||
CREATE TABLE IF NOT EXISTS drivers (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50) NOT NULL,
|
||||
department VARCHAR(255) DEFAULT 'Office of Development',
|
||||
user_id VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await this.query(`
|
||||
ALTER TABLE drivers
|
||||
ADD COLUMN IF NOT EXISTS phone VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS department VARCHAR(255) DEFAULT 'Office of Development',
|
||||
ADD COLUMN IF NOT EXISTS user_id VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL
|
||||
// Check and migrate schedule_events table
|
||||
const scheduleTableExists = await this.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'schedule_events'
|
||||
)
|
||||
`);
|
||||
|
||||
if (!scheduleTableExists.rows[0].exists) {
|
||||
// Check for old 'schedules' table and drop it
|
||||
const oldScheduleExists = await this.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'schedules'
|
||||
)
|
||||
`);
|
||||
|
||||
if (oldScheduleExists.rows[0].exists) {
|
||||
console.log('🔄 Migrating schedules table to schedule_events...');
|
||||
await this.query(`DROP TABLE IF EXISTS schedules CASCADE`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create schedule_events table
|
||||
await this.query(`
|
||||
CREATE TABLE IF NOT EXISTS schedule_events (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
@@ -388,42 +400,66 @@ class DatabaseService {
|
||||
)
|
||||
`);
|
||||
|
||||
// Create system_setup table for tracking initial setup
|
||||
await this.query(`
|
||||
DROP TABLE IF EXISTS schedules
|
||||
CREATE TABLE IF NOT EXISTS system_setup (
|
||||
id SERIAL PRIMARY KEY,
|
||||
setup_completed BOOLEAN DEFAULT false,
|
||||
first_admin_created BOOLEAN DEFAULT false,
|
||||
setup_date TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create admin_settings table
|
||||
await this.query(`
|
||||
ALTER TABLE schedule_events
|
||||
ADD COLUMN IF NOT EXISTS description TEXT,
|
||||
ADD COLUMN IF NOT EXISTS assigned_driver_id VARCHAR(255) REFERENCES drivers(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'scheduled',
|
||||
ADD COLUMN IF NOT EXISTS event_type VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
setting_key VARCHAR(255) UNIQUE NOT NULL,
|
||||
setting_value TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes for better performance
|
||||
await this.query(`CREATE INDEX IF NOT EXISTS idx_vips_transport_mode ON vips(transport_mode)`);
|
||||
await this.query(`CREATE INDEX IF NOT EXISTS idx_flights_vip_id ON flights(vip_id)`);
|
||||
await this.query(`CREATE INDEX IF NOT EXISTS idx_flights_date ON flights(flight_date)`);
|
||||
await this.query(`CREATE INDEX IF NOT EXISTS idx_schedule_events_vip_id ON schedule_events(vip_id)`);
|
||||
await this.query(`CREATE INDEX IF NOT EXISTS idx_schedule_events_driver_id ON schedule_events(assigned_driver_id)`);
|
||||
await this.query(`CREATE INDEX IF NOT EXISTS idx_schedule_events_start_time ON schedule_events(start_time)`);
|
||||
await this.query(`CREATE INDEX IF NOT EXISTS idx_schedule_events_status ON schedule_events(status)`);
|
||||
await this.query(`CREATE INDEX IF NOT EXISTS idx_drivers_user_id ON drivers(user_id)`);
|
||||
|
||||
// Create updated_at trigger function
|
||||
await this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_vips_transport_mode ON vips(transport_mode)
|
||||
`);
|
||||
await this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_flights_vip_id ON flights(vip_id)
|
||||
`);
|
||||
await this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_flights_date ON flights(flight_date)
|
||||
`);
|
||||
await this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_events_vip_id ON schedule_events(vip_id)
|
||||
`);
|
||||
await this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_events_driver_id ON schedule_events(assigned_driver_id)
|
||||
`);
|
||||
await this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_events_start_time ON schedule_events(start_time)
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql'
|
||||
`);
|
||||
|
||||
console.log('✅ VIP and schedule tables initialized successfully');
|
||||
// Create triggers for updated_at (drop if exists first)
|
||||
await this.query(`DROP TRIGGER IF EXISTS update_vips_updated_at ON vips`);
|
||||
await this.query(`DROP TRIGGER IF EXISTS update_flights_updated_at ON flights`);
|
||||
await this.query(`DROP TRIGGER IF EXISTS update_drivers_updated_at ON drivers`);
|
||||
await this.query(`DROP TRIGGER IF EXISTS update_schedule_events_updated_at ON schedule_events`);
|
||||
await this.query(`DROP TRIGGER IF EXISTS update_admin_settings_updated_at ON admin_settings`);
|
||||
|
||||
await this.query(`CREATE TRIGGER update_vips_updated_at BEFORE UPDATE ON vips FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
|
||||
await this.query(`CREATE TRIGGER update_flights_updated_at BEFORE UPDATE ON flights FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
|
||||
await this.query(`CREATE TRIGGER update_drivers_updated_at BEFORE UPDATE ON drivers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
|
||||
await this.query(`CREATE TRIGGER update_schedule_events_updated_at BEFORE UPDATE ON schedule_events FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
|
||||
await this.query(`CREATE TRIGGER update_admin_settings_updated_at BEFORE UPDATE ON admin_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()`);
|
||||
|
||||
console.log('✅ VIP Coordinator database schema initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize VIP tables:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -589,13 +589,11 @@ class EnhancedDataService {
|
||||
// Default settings structure
|
||||
const defaultSettings = {
|
||||
apiKeys: {
|
||||
aviationStackKey: process.env.AVIATIONSTACK_API_KEY || '',
|
||||
aviationStackKey: '',
|
||||
googleMapsKey: '',
|
||||
twilioKey: '',
|
||||
auth0Domain: process.env.AUTH0_DOMAIN || '',
|
||||
auth0ClientId: process.env.AUTH0_CLIENT_ID || '',
|
||||
auth0ClientSecret: process.env.AUTH0_CLIENT_SECRET || '',
|
||||
auth0Audience: process.env.AUTH0_AUDIENCE || ''
|
||||
googleClientId: '',
|
||||
googleClientSecret: ''
|
||||
},
|
||||
systemSettings: {
|
||||
defaultPickupLocation: '',
|
||||
|
||||
@@ -118,7 +118,7 @@ class FlightService {
|
||||
console.log('Note: Free tier returns recent flights only, not future scheduled flights');
|
||||
|
||||
const response = await fetch(url);
|
||||
const data: any = await response.json();
|
||||
const data = await response.json();
|
||||
|
||||
console.log('AviationStack response status:', response.status);
|
||||
|
||||
@@ -128,12 +128,12 @@ class FlightService {
|
||||
}
|
||||
|
||||
// Check for API errors in response
|
||||
if (data?.error) {
|
||||
if (data.error) {
|
||||
console.error('AviationStack API error:', data.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.data) && data.data.length > 0) {
|
||||
if (data.data && data.data.length > 0) {
|
||||
// This is a valid flight number that exists!
|
||||
console.log(`✅ Valid flight number: ${formattedFlightNumber} exists in the system`);
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: development
|
||||
environment:
|
||||
VITE_API_URL: http://localhost:3000/api
|
||||
ports:
|
||||
- 5173:5173
|
||||
depends_on:
|
||||
|
||||
@@ -6,55 +6,53 @@ services:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: vip_coordinator
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
target: production
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@db:5432/vip_coordinator
|
||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD:-changeme}@db:5432/vip_coordinator
|
||||
REDIS_URL: redis://redis:6379
|
||||
NODE_ENV: production
|
||||
FRONTEND_URL: ${FRONTEND_URL}
|
||||
AUTH0_DOMAIN: ${AUTH0_DOMAIN}
|
||||
AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID}
|
||||
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET}
|
||||
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE}
|
||||
INITIAL_ADMIN_EMAILS: ${INITIAL_ADMIN_EMAILS:-}
|
||||
AVIATIONSTACK_API_KEY: ${AVIATIONSTACK_API_KEY:-}
|
||||
DATABASE_SSL: ${DATABASE_SSL:-false}
|
||||
PGSSLMODE: disable
|
||||
JWT_SECRET: ${JWT_SECRET:-your-super-secure-jwt-secret-key-change-in-production-12345}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-your-super-secure-session-secret-change-in-production-67890}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-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}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-https://api.bsa.madeamess.online/auth/google/callback}
|
||||
FRONTEND_URL: ${FRONTEND_URL:-https://bsa.madeamess.online}
|
||||
AVIATIONSTACK_API_KEY: ${AVIATIONSTACK_API_KEY:-your-aviationstack-api-key}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||
PORT: ${PORT:-3000}
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: serve
|
||||
args:
|
||||
VITE_AUTH0_DOMAIN: ${AUTH0_DOMAIN}
|
||||
VITE_AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID}
|
||||
VITE_AUTH0_AUDIENCE: ${AUTH0_AUDIENCE}
|
||||
target: production
|
||||
environment:
|
||||
VITE_API_URL: ${VITE_API_URL:-https://api.bsa.madeamess.online/api}
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- /opt/vip-coordinator/certs:/etc/nginx/certs:ro
|
||||
- 5173:5173
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Multi-stage build for development and production
|
||||
FROM node:18-alpine AS base
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,20 +15,7 @@ CMD ["npm", "run", "dev"]
|
||||
|
||||
# Production stage
|
||||
FROM base AS production
|
||||
ARG VITE_AUTH0_DOMAIN
|
||||
ARG VITE_AUTH0_CLIENT_ID
|
||||
ARG VITE_AUTH0_AUDIENCE
|
||||
ENV VITE_AUTH0_DOMAIN=${VITE_AUTH0_DOMAIN}
|
||||
ENV VITE_AUTH0_CLIENT_ID=${VITE_AUTH0_CLIENT_ID}
|
||||
ENV VITE_AUTH0_AUDIENCE=${VITE_AUTH0_AUDIENCE}
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# Serve with nginx
|
||||
FROM nginx:alpine AS serve
|
||||
COPY --from=production /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -1,64 +1,51 @@
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name bsa.madeamess.online _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name bsa.madeamess.online _;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
location /auth/callback {
|
||||
try_files $uri /index.html;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://backend:3000/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,34 +3,38 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "node ./node_modules/vite/bin/vite.js",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "^2.8.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^4.5.14"
|
||||
"react-router-dom": "^6.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.4",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.47",
|
||||
"typescript": "^5.0.2"
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"postcss": "^8.5.4",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/postcss.config.mjs
Normal file
6
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
||||
@@ -1,105 +1,117 @@
|
||||
/* Modern App-specific styles using Tailwind utilities */
|
||||
|
||||
/* Enhanced button styles */
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-gradient-blue {
|
||||
@apply bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-green {
|
||||
@apply bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-purple {
|
||||
@apply bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-amber {
|
||||
@apply bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white;
|
||||
@layer components {
|
||||
.btn-modern {
|
||||
@apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
|
||||
}
|
||||
|
||||
.btn-gradient-blue {
|
||||
@apply bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-green {
|
||||
@apply bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-purple {
|
||||
@apply bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-amber {
|
||||
@apply bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold;
|
||||
}
|
||||
@layer components {
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold;
|
||||
}
|
||||
|
||||
.status-scheduled {
|
||||
@apply bg-blue-100 text-blue-800 border border-blue-200;
|
||||
}
|
||||
.status-scheduled {
|
||||
@apply bg-blue-100 text-blue-800 border border-blue-200;
|
||||
}
|
||||
|
||||
.status-in-progress {
|
||||
@apply bg-amber-100 text-amber-800 border border-amber-200;
|
||||
}
|
||||
.status-in-progress {
|
||||
@apply bg-amber-100 text-amber-800 border border-amber-200;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
@apply bg-green-100 text-green-800 border border-green-200;
|
||||
}
|
||||
.status-completed {
|
||||
@apply bg-green-100 text-green-800 border border-green-200;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
@apply bg-red-100 text-red-800 border border-red-200;
|
||||
.status-cancelled {
|
||||
@apply bg-red-100 text-red-800 border border-red-200;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card enhancements */
|
||||
.card-modern {
|
||||
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply bg-gradient-to-r from-slate-50 to-slate-100 px-6 py-4 border-b border-slate-200/60;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
@layer components {
|
||||
.card-modern {
|
||||
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply bg-gradient-to-r from-slate-50 to-slate-100 px-6 py-4 border-b border-slate-200/60;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading-spinner {
|
||||
@apply animate-spin rounded-full border-4 border-blue-600 border-t-transparent;
|
||||
}
|
||||
@layer components {
|
||||
.loading-spinner {
|
||||
@apply animate-spin rounded-full border-4 border-blue-600 border-t-transparent;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
@apply text-slate-600 animate-pulse;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-slate-200 rounded;
|
||||
.loading-text {
|
||||
@apply text-slate-600 animate-pulse;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-slate-200 rounded;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form enhancements */
|
||||
.form-modern {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.form-group-modern {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.form-label-modern {
|
||||
@apply block text-sm font-semibold text-slate-700;
|
||||
}
|
||||
|
||||
.form-input-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 transition-all duration-200;
|
||||
}
|
||||
|
||||
.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;
|
||||
@layer components {
|
||||
.form-modern {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.form-group-modern {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.form-label-modern {
|
||||
@apply block text-sm font-semibold text-slate-700;
|
||||
}
|
||||
|
||||
.form-input-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 transition-all duration-200;
|
||||
}
|
||||
|
||||
.form-select-modern {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white transition-all duration-200;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -149,23 +161,27 @@
|
||||
}
|
||||
|
||||
/* Glass morphism effect */
|
||||
.glass {
|
||||
@apply bg-white/80 backdrop-blur-lg border border-white/20;
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply bg-slate-900/80 backdrop-blur-lg border border-slate-700/20;
|
||||
@layer utilities {
|
||||
.glass {
|
||||
@apply bg-white/80 backdrop-blur-lg border border-white/20;
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply bg-slate-900/80 backdrop-blur-lg border border-slate-700/20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
.hover-lift {
|
||||
@apply transition-transform duration-200 hover:-translate-y-1;
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
@apply transition-shadow duration-200 hover:shadow-2xl;
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
@apply transition-transform duration-200 hover:scale-105;
|
||||
@layer utilities {
|
||||
.hover-lift {
|
||||
@apply transition-transform duration-200 hover:-translate-y-1;
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
@apply transition-shadow duration-200 hover:shadow-2xl;
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
@apply transition-transform duration-200 hover:scale-105;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { apiCall } from './config/api';
|
||||
import VipList from './pages/VipList';
|
||||
import VipDetails from './pages/VipDetails';
|
||||
@@ -12,102 +11,54 @@ import UserManagement from './components/UserManagement';
|
||||
import Login from './components/Login';
|
||||
import './App.css';
|
||||
|
||||
const AUTH0_AUDIENCE = import.meta.env.VITE_AUTH0_AUDIENCE;
|
||||
|
||||
function App() {
|
||||
const {
|
||||
isLoading: authLoading,
|
||||
isAuthenticated,
|
||||
loginWithRedirect,
|
||||
logout,
|
||||
getAccessTokenSilently,
|
||||
user: auth0User,
|
||||
error: authError
|
||||
} = useAuth0();
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [pendingApproval, setPendingApproval] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const bootstrap = async () => {
|
||||
if (!isAuthenticated) {
|
||||
setUser(null);
|
||||
setStatusMessage(null);
|
||||
setPendingApproval(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setPendingApproval(false);
|
||||
setStatusMessage(null);
|
||||
|
||||
try {
|
||||
const token = await getAccessTokenSilently({
|
||||
authorizationParams: {
|
||||
...(AUTH0_AUDIENCE ? { audience: AUTH0_AUDIENCE } : {}),
|
||||
scope: 'openid profile email'
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.setItem('authToken', token);
|
||||
|
||||
const response = await apiCall('/auth/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
const data = await response.json();
|
||||
setUser(null);
|
||||
setPendingApproval(true);
|
||||
setStatusMessage(data.message || 'Your account is pending administrator approval.');
|
||||
return;
|
||||
// Check if user is already authenticated
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
apiCall('/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load profile (${response.status})`);
|
||||
})
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
// Token is invalid, remove it
|
||||
localStorage.removeItem('authToken');
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
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 {
|
||||
})
|
||||
.then(userData => {
|
||||
setUser(userData);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authLoading) {
|
||||
bootstrap();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Auth check failed:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isAuthenticated, authLoading, getAccessTokenSilently, auth0User]);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (userData: any) => {
|
||||
setUser(userData);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
logout({ logoutParams: { returnTo: window.location.origin } });
|
||||
setUser(null);
|
||||
// Optionally call logout endpoint
|
||||
apiCall('/auth/logout', { method: 'POST' })
|
||||
.catch(error => console.error('Logout error:', error));
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4">
|
||||
@@ -118,68 +69,23 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingApproval) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-amber-50 to-rose-50 flex justify-center items-center px-4">
|
||||
<div className="bg-white border border-amber-200/60 rounded-2xl shadow-xl max-w-xl w-full p-8 space-y-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-3xl">
|
||||
⏳
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Awaiting Administrator Approval</h1>
|
||||
<p className="text-slate-600">
|
||||
{statusMessage ||
|
||||
'Thanks for signing in. An administrator needs to approve your account before you can access the dashboard.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-secondary mt-4"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Handle OAuth callback route even when not logged in
|
||||
if (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback') {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
const beginLogin = async () => {
|
||||
try {
|
||||
await loginWithRedirect({
|
||||
authorizationParams: {
|
||||
...(AUTH0_AUDIENCE ? { audience: AUTH0_AUDIENCE } : {}),
|
||||
scope: 'openid profile email',
|
||||
redirect_uri: `${window.location.origin}/auth/callback`
|
||||
}
|
||||
});
|
||||
} 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}
|
||||
/>
|
||||
);
|
||||
if (!user) {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
const displayName =
|
||||
(user.name && user.name.trim().length > 0)
|
||||
? user.name
|
||||
: (user.email || 'User');
|
||||
const displayInitial = displayName.trim().charAt(0).toUpperCase();
|
||||
const userRole = user.role || 'user';
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||
{/* Modern Navigation */}
|
||||
<nav className="bg-white/80 backdrop-blur-lg border-b border-slate-200/60 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo/Brand */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">VC</span>
|
||||
@@ -189,36 +95,37 @@ function App() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="hidden md:flex items-center space-x-1">
|
||||
<Link
|
||||
to="/"
|
||||
<Link
|
||||
to="/"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/vips"
|
||||
<Link
|
||||
to="/vips"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
VIPs
|
||||
</Link>
|
||||
<Link
|
||||
to="/drivers"
|
||||
<Link
|
||||
to="/drivers"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Drivers
|
||||
</Link>
|
||||
{userRole === 'administrator' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
{(user.role === 'administrator' || user.role === 'coordinator') && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
{userRole === 'administrator' && (
|
||||
<Link
|
||||
to="/users"
|
||||
{user.role === 'administrator' && (
|
||||
<Link
|
||||
to="/users"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Users
|
||||
@@ -226,23 +133,20 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="hidden sm:flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center overflow-hidden">
|
||||
{user.picture ? (
|
||||
<img src={user.picture} alt={displayName} className="w-8 h-8 object-cover" />
|
||||
) : (
|
||||
<span className="text-white text-xs font-medium">
|
||||
{displayInitial}
|
||||
</span>
|
||||
)}
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs font-medium">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-slate-900">{displayName}</div>
|
||||
<div className="text-slate-500 capitalize">{userRole}</div>
|
||||
<div className="font-medium text-slate-900">{user.name}</div>
|
||||
<div className="text-slate-500 capitalize">{user.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
@@ -253,6 +157,7 @@ function App() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
|
||||
@@ -94,101 +94,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dev-login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.dev-login-divider::before,
|
||||
.dev-login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.dev-login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dev-login-form h3 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dev-login-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dev-login-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.dev-login-form input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5f5;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dev-login-form input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.dev-login-error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dev-login-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dev-login-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.dev-login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 20px;
|
||||
|
||||
@@ -3,15 +3,15 @@ import { apiCall } from '../config/api';
|
||||
import './Login.css';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void;
|
||||
errorMessage?: string | null | undefined;
|
||||
onLogin: (user: any) => void;
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
|
||||
const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
const [setupStatus, setSetupStatus] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check system setup status
|
||||
apiCall('/auth/setup')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
@@ -22,7 +22,82 @@ const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
|
||||
console.error('Error checking setup status:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check for OAuth callback code in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const error = urlParams.get('error');
|
||||
const token = urlParams.get('token');
|
||||
|
||||
if (code && (window.location.pathname === '/auth/google/callback' || window.location.pathname === '/auth/callback')) {
|
||||
// Exchange code for token
|
||||
apiCall('/auth/google/exchange', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code })
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to exchange code for token');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(({ token, user }) => {
|
||||
localStorage.setItem('authToken', token);
|
||||
onLogin(user);
|
||||
// Clean up URL and redirect to dashboard
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('OAuth exchange failed:', error);
|
||||
alert('Login failed. Please try again.');
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
});
|
||||
} else if (token && (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback')) {
|
||||
// Direct token from URL (from backend redirect)
|
||||
localStorage.setItem('authToken', token);
|
||||
|
||||
apiCall('/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(user => {
|
||||
onLogin(user);
|
||||
// Clean up URL and redirect to dashboard
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error getting user info:', error);
|
||||
localStorage.removeItem('authToken');
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
});
|
||||
} else if (error) {
|
||||
console.error('Authentication error:', error);
|
||||
alert(`Login error: ${error}`);
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
}
|
||||
}, [onLogin]);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
// Get OAuth URL from backend
|
||||
const response = await apiCall('/auth/google/url');
|
||||
const { url } = await response.json();
|
||||
|
||||
// Redirect to Google OAuth
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error('Failed to get OAuth URL:', error);
|
||||
alert('Login failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -45,38 +120,55 @@ const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
|
||||
{!setupStatus?.firstAdminCreated && (
|
||||
<div className="setup-notice">
|
||||
<h3>🚀 First Time Setup</h3>
|
||||
<p>The first person to sign in will be promoted to administrator automatically.</p>
|
||||
<p>The first person to log in will become the system administrator.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="login-content">
|
||||
<button
|
||||
<button
|
||||
className="google-login-btn"
|
||||
onClick={onLogin}
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={false}
|
||||
>
|
||||
<svg className="google-icon" viewBox="0 0 24 24">
|
||||
<path fill="#635dff" d="M22 12.07c0-5.52-4.48-10-10-10s-10 4.48-10 10a9.97 9.97 0 006.85 9.48.73.73 0 00.95-.7v-3.05c-2.79.61-3.38-1.19-3.38-1.19-.46-1.17-1.12-1.49-1.12-1.49-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.37 1.09 2.96.83.09-.65.35-1.09.63-1.34-2.23-.25-4.57-1.12-4.57-4.96 0-1.1.39-2 1.03-2.7-.1-.25-.45-1.25.1-2.6 0 0 .84-.27 2.75 1.02a9.53 9.53 0 015 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.35.1 2.6.64.7 1.03 1.6 1.03 2.7 0 3.85-2.34 4.71-4.58 4.95.36.31.69.92.69 1.86v2.75c0 .39.27.71.66.79a10 10 0 007.61-9.71z"/>
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Continue with Auth0
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<div className="login-info">
|
||||
<p>
|
||||
{setupStatus?.authProvider === 'auth0'
|
||||
? 'Sign in with your organisation account. We use Auth0 for secure authentication.'
|
||||
: 'Authentication service is being configured. Please try again later.'}
|
||||
{setupStatus?.firstAdminCreated
|
||||
? "Sign in with your Google account to access the VIP Coordinator."
|
||||
: "Sign in with Google to set up your administrator account."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="dev-login-error" style={{ marginTop: '1rem' }}>
|
||||
{errorMessage}
|
||||
{setupStatus && !setupStatus.setupCompleted && (
|
||||
<div style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #ffeaa7',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
<strong>⚠️ Setup Required:</strong>
|
||||
<p style={{ margin: '0.5rem 0 0 0' }}>
|
||||
Google OAuth credentials need to be configured. If the login doesn't work,
|
||||
please follow the setup guide in <code>GOOGLE_OAUTH_SETUP.md</code> to configure
|
||||
your Google Cloud Console credentials in the admin dashboard.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>Secure authentication powered by Auth0</p>
|
||||
<p>Secure authentication powered by Google OAuth</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
const DEFAULT_API_BASE =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export const API_BASE_URL =
|
||||
import.meta.env.VITE_API_URL?.replace(/\/$/, '') || DEFAULT_API_BASE;
|
||||
// API Configuration
|
||||
// Use environment variable with fallback to production URL
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://api.bsa.madeamess.online';
|
||||
|
||||
// Helper function for API calls
|
||||
export const apiCall = (endpoint: string, options?: RequestInit) => {
|
||||
const url = /^https?:\/\//.test(endpoint)
|
||||
? endpoint
|
||||
: `${API_BASE_URL}${endpoint}`;
|
||||
const url = endpoint.startsWith('/') ? `${API_BASE_URL}${endpoint}` : endpoint;
|
||||
return fetch(url, options);
|
||||
};
|
||||
|
||||
@@ -1,49 +1,4 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom base styles */
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom base styles */
|
||||
@layer base {
|
||||
@@ -92,117 +47,304 @@
|
||||
@layer components {
|
||||
/* Modern Button Styles */
|
||||
.btn {
|
||||
@apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
ring: 2px;
|
||||
ring-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-0.125rem);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white focus:ring-blue-500;
|
||||
background: linear-gradient(to right, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(to right, #2563eb, #1d4ed8);
|
||||
}
|
||||
|
||||
.btn-primary:focus {
|
||||
ring-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white focus:ring-slate-500;
|
||||
background: linear-gradient(to right, #64748b, #475569);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: linear-gradient(to right, #475569, #334155);
|
||||
}
|
||||
|
||||
.btn-secondary:focus {
|
||||
ring-color: #64748b;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white focus:ring-red-500;
|
||||
background: linear-gradient(to right, #ef4444, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: linear-gradient(to right, #dc2626, #b91c1c);
|
||||
}
|
||||
|
||||
.btn-danger:focus {
|
||||
ring-color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white focus:ring-green-500;
|
||||
background: linear-gradient(to right, #22c55e, #16a34a);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: linear-gradient(to right, #16a34a, #15803d);
|
||||
}
|
||||
|
||||
.btn-success:focus {
|
||||
ring-color: #22c55e;
|
||||
}
|
||||
|
||||
/* Modern Card Styles */
|
||||
.card {
|
||||
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
|
||||
background-color: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Modern Form Styles */
|
||||
.form-group {
|
||||
@apply mb-6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-semibold text-slate-700 mb-3;
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
background-color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white transition-all duration-200;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
background-color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white resize-none;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
background-color: white;
|
||||
transition: all 0.2s;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
@apply w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500 focus:ring-2;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #2563eb;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.form-checkbox:focus {
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
@apply w-4 h-4 text-blue-600 border-slate-300 focus:ring-blue-500 focus:ring-2;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #2563eb;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.form-radio:focus {
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 bg-black/50 backdrop-blur-sm flex justify-center items-center z-50 p-4;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto;
|
||||
background-color: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
max-width: 56rem;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60;
|
||||
background: linear-gradient(to right, #eff6ff, #eef2ff);
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@apply p-8;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply bg-slate-50 px-8 py-6 border-t border-slate-200/60 flex justify-end space-x-4;
|
||||
background-color: #f8fafc;
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.6);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Form Actions */
|
||||
.form-actions {
|
||||
@apply flex justify-end space-x-4 pt-6 border-t border-slate-200/60 mt-8;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.6);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Form Sections */
|
||||
.form-section {
|
||||
@apply bg-slate-50 rounded-xl p-6 mb-6 border border-slate-200/60;
|
||||
background-color: #f8fafc;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
}
|
||||
|
||||
.form-section-header {
|
||||
@apply flex items-center justify-between mb-4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
@apply text-lg font-bold text-slate-800;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Radio Group */
|
||||
.radio-group {
|
||||
@apply flex gap-6 mt-3;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
@apply flex items-center cursor-pointer bg-white rounded-lg px-4 py-3 border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all duration-200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.radio-option:hover {
|
||||
border-color: #93c5fd;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.radio-option.selected {
|
||||
@apply border-blue-500 bg-blue-50 ring-2 ring-blue-200;
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
ring: 2px;
|
||||
ring-color: #bfdbfe;
|
||||
}
|
||||
|
||||
/* Checkbox Group */
|
||||
.checkbox-option {
|
||||
@apply flex items-center cursor-pointer bg-white rounded-lg px-4 py-3 border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all duration-200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-option:hover {
|
||||
border-color: #93c5fd;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.checkbox-option.checked {
|
||||
@apply border-blue-500 bg-blue-50;
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Auth0Provider } from '@auth0/auth0-react';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
|
||||
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
|
||||
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
|
||||
|
||||
if (!domain || !clientId) {
|
||||
throw new Error('Auth0 environment variables are missing. Please set VITE_AUTH0_DOMAIN and VITE_AUTH0_CLIENT_ID.');
|
||||
}
|
||||
|
||||
const authorizationParams: Record<string, string> = {
|
||||
redirect_uri: `${window.location.origin}/auth/callback`,
|
||||
scope: 'openid profile email'
|
||||
};
|
||||
|
||||
if (audience) {
|
||||
authorizationParams.audience = audience;
|
||||
}
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Auth0Provider
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
authorizationParams={authorizationParams}
|
||||
cacheLocation="localstorage"
|
||||
useRefreshTokens={true}
|
||||
>
|
||||
<App />
|
||||
</Auth0Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiCall, API_BASE_URL } from '../config/api';
|
||||
import { apiCall } from '../config/api';
|
||||
import { generateTestVips, getTestOrganizations, generateVipSchedule } from '../utils/testVipData';
|
||||
|
||||
interface ApiKeys {
|
||||
aviationStackKey?: string;
|
||||
googleMapsKey?: string;
|
||||
twilioKey?: string;
|
||||
auth0Domain?: string;
|
||||
auth0ClientId?: string;
|
||||
auth0ClientSecret?: string;
|
||||
auth0Audience?: string;
|
||||
googleClientId?: string;
|
||||
googleClientSecret?: string;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
@@ -22,89 +20,92 @@ interface SystemSettings {
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeys>({});
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({});
|
||||
const [testResults, setTestResults] = useState<{ [key: string]: string }>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
||||
const [showKeys, setShowKeys] = useState<{ [key: string]: boolean }>({});
|
||||
const [savedKeys, setSavedKeys] = useState<{ [key: string]: boolean }>({});
|
||||
const [maskedKeyHints, setMaskedKeyHints] = useState<{ [key: string]: string }>({});
|
||||
const [testDataLoading, setTestDataLoading] = useState(false);
|
||||
const [testDataStatus, setTestDataStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const buildAuthHeaders = (includeJson = false) => {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
useEffect(() => {
|
||||
// Check if already authenticated
|
||||
const authStatus = sessionStorage.getItem('adminAuthenticated');
|
||||
if (authStatus === 'true') {
|
||||
setIsAuthenticated(true);
|
||||
loadSettings();
|
||||
}
|
||||
if (includeJson) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/authenticate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: adminPassword })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsAuthenticated(true);
|
||||
sessionStorage.setItem('adminAuthenticated', 'true');
|
||||
loadSettings();
|
||||
} else {
|
||||
alert('Invalid admin password');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Authentication failed');
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiCall('/api/admin/settings', {
|
||||
headers: buildAuthHeaders()
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
headers: {
|
||||
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Track which keys are already saved (masked keys start with ***)
|
||||
const saved: { [key: string]: boolean } = {};
|
||||
const maskedHints: { [key: string]: string } = {};
|
||||
const cleanedApiKeys: ApiKeys = {};
|
||||
|
||||
if (data.apiKeys) {
|
||||
Object.entries(data.apiKeys).forEach(([key, value]) => {
|
||||
if (value && (value as string).startsWith('***')) {
|
||||
saved[key] = true;
|
||||
maskedHints[key] = value as string;
|
||||
} else if (value) {
|
||||
}
|
||||
});
|
||||
}
|
||||
setSavedKeys(saved);
|
||||
// Don't load masked keys as actual values - keep them empty
|
||||
const cleanedApiKeys: ApiKeys = {};
|
||||
if (data.apiKeys) {
|
||||
Object.entries(data.apiKeys).forEach(([key, value]) => {
|
||||
// Only set the value if it's not a masked key
|
||||
if (value && !(value as string).startsWith('***')) {
|
||||
cleanedApiKeys[key as keyof ApiKeys] = value as string;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSavedKeys(saved);
|
||||
setMaskedKeyHints(maskedHints);
|
||||
setApiKeys(cleanedApiKeys);
|
||||
setSystemSettings(data.systemSettings || {});
|
||||
} else if (response.status === 403) {
|
||||
setError('You need administrator access to view this page.');
|
||||
} else if (response.status === 401) {
|
||||
setError('Authentication required. Please sign in again.');
|
||||
} else {
|
||||
setError('Failed to load admin settings.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
setError('Failed to load admin settings.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleApiKeyChange = (key: keyof ApiKeys, value: string) => {
|
||||
setApiKeys(prev => ({ ...prev, [key]: value }));
|
||||
// If user is typing a new key, mark it as not saved anymore
|
||||
if (value && !value.startsWith('***')) {
|
||||
setSavedKeys(prev => ({ ...prev, [key]: false }));
|
||||
setMaskedKeyHints(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,9 +117,12 @@ const AdminDashboard: React.FC = () => {
|
||||
setTestResults(prev => ({ ...prev, [apiType]: 'Testing...' }));
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/api/admin/test-api/${apiType}`, {
|
||||
const response = await fetch(`/api/admin/test-api/${apiType}`, {
|
||||
method: 'POST',
|
||||
headers: buildAuthHeaders(true),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: apiKeys[apiType as keyof ApiKeys]
|
||||
})
|
||||
@@ -146,13 +150,16 @@ const AdminDashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setSaving(true);
|
||||
setLoading(true);
|
||||
setSaveStatus(null);
|
||||
|
||||
try {
|
||||
const response = await apiCall('/api/admin/settings', {
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: buildAuthHeaders(true),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKeys,
|
||||
systemSettings
|
||||
@@ -161,8 +168,16 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
if (response.ok) {
|
||||
setSaveStatus('Settings saved successfully!');
|
||||
// Refresh the latest settings so saved states/labels stay accurate
|
||||
await loadSettings();
|
||||
// Mark keys as saved if they have values
|
||||
const newSavedKeys: { [key: string]: boolean } = {};
|
||||
Object.entries(apiKeys).forEach(([key, value]) => {
|
||||
if (value && !value.startsWith('***')) {
|
||||
newSavedKeys[key] = true;
|
||||
}
|
||||
});
|
||||
setSavedKeys(prev => ({ ...prev, ...newSavedKeys }));
|
||||
// Clear the input fields after successful save
|
||||
setApiKeys({});
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} else {
|
||||
setSaveStatus('Failed to save settings');
|
||||
@@ -170,14 +185,14 @@ const AdminDashboard: React.FC = () => {
|
||||
} catch (error) {
|
||||
setSaveStatus('Error saving settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('adminAuthenticated');
|
||||
setIsAuthenticated(false);
|
||||
navigate('/');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Test VIP functions
|
||||
@@ -337,29 +352,37 @@ const AdminDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4 border border-slate-200/60">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-lg font-medium text-slate-700">Loading admin settings...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-xl border border-rose-200/70">
|
||||
<h2 className="text-2xl font-bold text-rose-700 mb-4">Admin access required</h2>
|
||||
<p className="text-slate-600 mb-6">{error}</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Return to dashboard
|
||||
</button>
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md border border-slate-200/60">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-amber-500 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Admin Login</h2>
|
||||
<p className="text-slate-600 mt-2">Enter your admin password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">Admin Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Enter admin password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -415,20 +438,24 @@ const AdminDashboard: React.FC = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-end">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="form-label">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={savedKeys.aviationStackKey && maskedKeyHints.aviationStackKey
|
||||
? `Saved (${maskedKeyHints.aviationStackKey.slice(-4)})`
|
||||
: 'Enter AviationStack API key'}
|
||||
value={apiKeys.aviationStackKey || ''}
|
||||
onChange={(e) => handleApiKeyChange('aviationStackKey', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
{savedKeys.aviationStackKey && maskedKeyHints.aviationStackKey && !apiKeys.aviationStackKey && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Currently saved key ends with {maskedKeyHints.aviationStackKey.slice(-4)}. Enter a new value to replace it.
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKeys.aviationStackKey ? 'text' : 'password'}
|
||||
placeholder={savedKeys.aviationStackKey ? 'Key saved (enter new key to update)' : 'Enter AviationStack API key'}
|
||||
value={apiKeys.aviationStackKey || ''}
|
||||
onChange={(e) => handleApiKeyChange('aviationStackKey', e.target.value)}
|
||||
className="form-input pr-12"
|
||||
/>
|
||||
{savedKeys.aviationStackKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKeys(prev => ({ ...prev, aviationStackKey: !prev.aviationStackKey }))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.aviationStackKey ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Get your key from: https://aviationstack.com/dashboard
|
||||
</p>
|
||||
@@ -457,91 +484,72 @@ const AdminDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth0 Credentials */}
|
||||
{/* Google OAuth Credentials */}
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">Auth0 Configuration</h3>
|
||||
{(savedKeys.auth0Domain || savedKeys.auth0ClientId || savedKeys.auth0ClientSecret) && (
|
||||
<h3 className="form-section-title">Google OAuth Credentials</h3>
|
||||
{(savedKeys.googleClientId && savedKeys.googleClientSecret) && (
|
||||
<span className="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Auth0 Domain</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={savedKeys.auth0Domain && maskedKeyHints.auth0Domain
|
||||
? `Saved (${maskedKeyHints.auth0Domain.slice(-4)})`
|
||||
: 'e.g. dev-1234abcd.us.auth0.com'}
|
||||
value={apiKeys.auth0Domain || ''}
|
||||
onChange={(e) => handleApiKeyChange('auth0Domain', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Client ID</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={savedKeys.auth0ClientId && maskedKeyHints.auth0ClientId
|
||||
? `Saved (${maskedKeyHints.auth0ClientId.slice(-4)})`
|
||||
: 'Enter Auth0 application Client ID'}
|
||||
value={apiKeys.auth0ClientId || ''}
|
||||
onChange={(e) => handleApiKeyChange('auth0ClientId', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
{savedKeys.auth0ClientId && maskedKeyHints.auth0ClientId && !apiKeys.auth0ClientId && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Saved client ID ends with {maskedKeyHints.auth0ClientId.slice(-4)}. Provide a new ID to update it.
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKeys.googleClientId ? 'text' : 'password'}
|
||||
placeholder={savedKeys.googleClientId ? 'Client ID saved' : 'Enter Google OAuth Client ID'}
|
||||
value={apiKeys.googleClientId || ''}
|
||||
onChange={(e) => handleApiKeyChange('googleClientId', e.target.value)}
|
||||
className="form-input pr-12"
|
||||
/>
|
||||
{savedKeys.googleClientId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKeys(prev => ({ ...prev, googleClientId: !prev.googleClientId }))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.googleClientId ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={savedKeys.auth0ClientSecret && maskedKeyHints.auth0ClientSecret
|
||||
? `Saved (${maskedKeyHints.auth0ClientSecret.slice(-4)})`
|
||||
: 'Enter Auth0 application Client Secret'}
|
||||
value={apiKeys.auth0ClientSecret || ''}
|
||||
onChange={(e) => handleApiKeyChange('auth0ClientSecret', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
{savedKeys.auth0ClientSecret && maskedKeyHints.auth0ClientSecret && !apiKeys.auth0ClientSecret && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Saved client secret ends with {maskedKeyHints.auth0ClientSecret.slice(-4)}. Provide a new secret to rotate it.
|
||||
</p>
|
||||
)}
|
||||
</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 className="relative">
|
||||
<input
|
||||
type={showKeys.googleClientSecret ? 'text' : 'password'}
|
||||
placeholder={savedKeys.googleClientSecret ? 'Client Secret saved' : 'Enter Google OAuth Client Secret'}
|
||||
value={apiKeys.googleClientSecret || ''}
|
||||
onChange={(e) => handleApiKeyChange('googleClientSecret', e.target.value)}
|
||||
className="form-input pr-12"
|
||||
/>
|
||||
{savedKeys.googleClientSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKeys(prev => ({ ...prev, googleClientSecret: !prev.googleClientSecret }))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.googleClientSecret ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">Setup Instructions</h4>
|
||||
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
|
||||
<li>Sign in to the Auth0 Dashboard</li>
|
||||
<li>Create a <strong>Single Page Application</strong> for the frontend</li>
|
||||
<li>Set Allowed Callback URL to <code>https://bsa.madeamess.online/auth/callback</code></li>
|
||||
<li>Set Allowed Logout URL to <code>https://bsa.madeamess.online/</code></li>
|
||||
<li>Set Allowed Web Origins to <code>https://bsa.madeamess.online</code></li>
|
||||
<li>Create an <strong>API</strong> in Auth0 for the backend and use its Identifier as the audience</li>
|
||||
<li>Go to Google Cloud Console</li>
|
||||
<li>Create or select a project</li>
|
||||
<li>Enable the Google+ API</li>
|
||||
<li>Go to "Credentials" → "Create Credentials" → "OAuth 2.0 Client IDs"</li>
|
||||
<li>Set authorized redirect URI: http://bsa.madeamess.online:3000/auth/google/callback</li>
|
||||
<li>Set authorized JavaScript origins: http://bsa.madeamess.online:5173</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -751,7 +759,7 @@ const AdminDashboard: React.FC = () => {
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary w-full mb-2"
|
||||
onClick={() => window.open(`${API_BASE_URL}/api-docs.html`, '_blank')}
|
||||
onClick={() => window.open('http://localhost:3000/api-docs.html', '_blank')}
|
||||
>
|
||||
Open API Documentation
|
||||
</button>
|
||||
@@ -803,9 +811,9 @@ const AdminDashboard: React.FC = () => {
|
||||
<button
|
||||
className="btn btn-success text-lg px-8 py-4"
|
||||
onClick={saveSettings}
|
||||
disabled={saving}
|
||||
disabled={loading}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save All Settings'}
|
||||
{loading ? 'Saving...' : 'Save All Settings'}
|
||||
</button>
|
||||
|
||||
{saveStatus && (
|
||||
|
||||
@@ -8,5 +8,4 @@ export default {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
],
|
||||
css: {
|
||||
postcss: './postcss.config.js',
|
||||
postcss: './postcss.config.mjs',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
@@ -45,10 +47,6 @@ export default defineConfig({
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth/dev-login': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user