diff --git a/.env.example b/.env.example index 9543133..f02da76 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,27 @@ -# VIP Coordinator Environment Configuration -# Copy this file to .env and update the values for your deployment - # Database Configuration -DB_PASSWORD=VipCoord2025SecureDB +POSTGRES_DB=vip_coordinator +POSTGRES_USER=vip_user +POSTGRES_PASSWORD=your_secure_password_here +DATABASE_URL=postgresql://vip_user:your_secure_password_here@db:5432/vip_coordinator -# Domain Configuration (Update these for your domain) -DOMAIN=your-domain.com -VITE_API_URL=https://api.your-domain.com +# Redis Configuration +REDIS_URL=redis://redis:6379 -# Google OAuth Configuration (Get these from Google Cloud Console) -GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_REDIRECT_URI=https://api.your-domain.com/auth/google/callback +# Google OAuth Configuration +GOOGLE_CLIENT_ID=your_google_client_id_here +GOOGLE_CLIENT_SECRET=your_google_client_secret_here +GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback +FRONTEND_URL=http://localhost:5173 -# Frontend URL -FRONTEND_URL=https://your-domain.com +# JWT Configuration +JWT_SECRET=your_jwt_secret_here_minimum_32_characters_long -# Admin Configuration -ADMIN_PASSWORD=ChangeThisSecurePassword +# Environment +NODE_ENV=development -# Flight API Configuration (Optional) -AVIATIONSTACK_API_KEY=your-aviationstack-api-key +# API Configuration +API_PORT=3000 -# Port Configuration -PORT=3000 \ No newline at end of file +# Frontend Configuration (for production) +VITE_API_URL=http://localhost:3000/api +VITE_GOOGLE_CLIENT_ID=your_google_client_id_here \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 0f079a9..f658162 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,266 +1,232 @@ # ๐Ÿš€ VIP Coordinator - Docker Hub Deployment Guide -Deploy the VIP Coordinator application on any system with Docker in just a few steps! +## ๐Ÿ“‹ Quick Start -## ๐Ÿ“‹ Prerequisites +### Prerequisites +- Docker and Docker Compose installed +- Google Cloud Console account (for OAuth setup) -- **Docker** and **Docker Compose** installed on your system -- **Domain name** (optional, can run on localhost for testing) -- **Google Cloud Console** account for OAuth setup - -## ๐Ÿš€ Quick Start (5 Minutes) - -### 1. Download Deployment Files - -Create a new directory and download these files: +### 1. Download and Configure ```bash -mkdir vip-coordinator +# Pull the project +git clone cd vip-coordinator -# Download the deployment files -curl -O https://raw.githubusercontent.com/your-repo/vip-coordinator/main/docker-compose.yml -curl -O https://raw.githubusercontent.com/your-repo/vip-coordinator/main/.env.example +# Copy environment template +cp .env.example .env.prod + +# Edit with your configuration +nano .env.prod ``` -### 2. Configure Environment +### 2. Required Configuration + +Edit `.env.prod` with your values: ```bash -# Copy the environment template -cp .env.example .env +# Database Configuration +DB_PASSWORD=your-secure-database-password -# Edit the configuration (use your preferred editor) -nano .env +# Domain Configuration (update with your domains) +DOMAIN=your-domain.com +VITE_API_URL=https://api.your-domain.com/api + +# Google OAuth Configuration (from Google Cloud Console) +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_REDIRECT_URI=https://api.your-domain.com/auth/google/callback + +# Frontend URL +FRONTEND_URL=https://your-domain.com + +# Admin Configuration +ADMIN_PASSWORD=your-secure-admin-password ``` -**Required Changes in `.env`:** -- `DB_PASSWORD`: Change to a secure password -- `ADMIN_PASSWORD`: Change to a secure password -- `GOOGLE_CLIENT_ID`: Your Google OAuth Client ID -- `GOOGLE_CLIENT_SECRET`: Your Google OAuth Client Secret +### 3. Google OAuth Setup -**For Production Deployment:** -- `DOMAIN`: Your domain name (e.g., `mycompany.com`) -- `VITE_API_URL`: Your API URL (e.g., `https://api.mycompany.com`) -- `GOOGLE_REDIRECT_URI`: Your callback URL (e.g., `https://api.mycompany.com/auth/google/callback`) -- `FRONTEND_URL`: Your frontend URL (e.g., `https://mycompany.com`) +1. **Create Google Cloud Project**: + - Go to [Google Cloud Console](https://console.cloud.google.com/) + - Create a new project -### 3. Set Up Google OAuth +2. **Enable Google+ API**: + - Navigate to "APIs & Services" > "Library" + - Search for "Google+ API" and enable it -1. Go to [Google Cloud Console](https://console.cloud.google.com/) -2. Create a new project or select existing one -3. Enable the Google+ API -4. Go to "Credentials" โ†’ "Create Credentials" โ†’ "OAuth 2.0 Client IDs" -5. Set application type to "Web application" -6. Add authorized redirect URIs: - - For localhost: `http://localhost:3000/auth/google/callback` - - For production: `https://api.your-domain.com/auth/google/callback` -7. Copy the Client ID and Client Secret to your `.env` file +3. **Create OAuth Credentials**: + - Go to "APIs & Services" > "Credentials" + - Click "Create Credentials" > "OAuth 2.0 Client IDs" + - Application type: "Web application" + - Authorized redirect URIs: `https://api.your-domain.com/auth/google/callback` -### 4. Deploy the Application +### 4. Deploy ```bash -# Pull the latest images from Docker Hub -docker-compose pull - # Start the application -docker-compose up -d +docker-compose -f docker-compose.prod.yml up -d # Check status -docker-compose ps +docker-compose -f docker-compose.prod.yml ps + +# View logs +docker-compose -f docker-compose.prod.yml logs -f ``` -### 5. Access the Application +### 5. Access Your Application -- **Local Development**: http://localhost -- **Production**: https://your-domain.com +- **Frontend**: http://your-domain.com (or http://localhost if running locally) +- **Backend API**: http://api.your-domain.com (or http://localhost:3000) +- **API Documentation**: http://api.your-domain.com/api-docs.html -## ๐Ÿ”ง Configuration Options +### 6. First Login + +- Visit your frontend URL +- Click "Continue with Google" +- The first user becomes the system administrator +- Subsequent users need admin approval + +## ๐Ÿ”ง Configuration Details ### Environment Variables -| Variable | Description | Required | Default | -|----------|-------------|----------|---------| -| `DB_PASSWORD` | PostgreSQL database password | โœ… | - | -| `ADMIN_PASSWORD` | Admin interface password | โœ… | - | -| `GOOGLE_CLIENT_ID` | Google OAuth Client ID | โœ… | - | -| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | โœ… | - | -| `GOOGLE_REDIRECT_URI` | OAuth callback URL | โœ… | - | -| `FRONTEND_URL` | Frontend application URL | โœ… | - | -| `VITE_API_URL` | Backend API URL | โœ… | - | -| `DOMAIN` | Your domain name | โŒ | localhost | -| `AVIATIONSTACK_API_KEY` | Flight data API key | โŒ | - | -| `PORT` | Backend port | โŒ | 3000 | +| Variable | Required | Description | Example | +|----------|----------|-------------|---------| +| `DB_PASSWORD` | โœ… | PostgreSQL database password | `SecurePass123!` | +| `DOMAIN` | โœ… | Your main domain | `example.com` | +| `VITE_API_URL` | โœ… | API endpoint URL | `https://api.example.com/api` | +| `GOOGLE_CLIENT_ID` | โœ… | Google OAuth client ID | `123456789-abc.apps.googleusercontent.com` | +| `GOOGLE_CLIENT_SECRET` | โœ… | Google OAuth client secret | `GOCSPX-abcdef123456` | +| `GOOGLE_REDIRECT_URI` | โœ… | OAuth redirect URI | `https://api.example.com/auth/google/callback` | +| `FRONTEND_URL` | โœ… | Frontend URL | `https://example.com` | +| `ADMIN_PASSWORD` | โœ… | Admin panel password | `AdminPass123!` | -### Ports +### Optional Configuration -- **Frontend**: Port 80 (HTTP) -- **Backend**: Port 3000 (API) -- **Database**: Internal only (PostgreSQL) -- **Redis**: Internal only (Cache) +- **AviationStack API Key**: Configure via admin interface for flight tracking +- **Custom Ports**: Modify docker-compose.prod.yml if needed -## ๐ŸŒ Production Deployment +## ๐Ÿ—๏ธ Architecture -### With Reverse Proxy (Recommended) +### Services +- **Frontend**: React app served by Nginx (Port 80) +- **Backend**: Node.js API server (Port 3000) +- **Database**: PostgreSQL with automatic schema setup +- **Redis**: Caching and real-time updates -For production, use a reverse proxy like Nginx or Traefik: +### Security Features +- JWT tokens with automatic key rotation (24-hour cycle) +- Non-root containers for enhanced security +- Health checks for all services +- Secure headers and CORS configuration -```nginx -# Nginx configuration example -server { - listen 80; - server_name your-domain.com; - return 301 https://$server_name$request_uri; -} +## ๐Ÿ” Security Best Practices -server { - listen 443 ssl; - server_name your-domain.com; - - # SSL configuration - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/key.pem; - - location / { - proxy_pass http://localhost:80; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } -} +### Required Changes +1. **Change default passwords**: Update `DB_PASSWORD` and `ADMIN_PASSWORD` +2. **Use HTTPS**: Configure SSL/TLS certificates for production +3. **Secure domains**: Use your own domains, not the examples +4. **Google OAuth**: Create your own OAuth credentials -server { - listen 443 ssl; - server_name api.your-domain.com; - - # SSL configuration - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/key.pem; - - location / { - proxy_pass http://localhost:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } -} -``` +### Recommended +- Use strong, unique passwords (20+ characters) +- Enable firewall rules for your server +- Regular security updates for the host system +- Monitor logs for suspicious activity -### SSL/HTTPS Setup - -1. Obtain SSL certificates (Let's Encrypt recommended) -2. Configure your reverse proxy for HTTPS -3. Update your `.env` file with HTTPS URLs -4. Update Google OAuth redirect URIs to use HTTPS - -## ๐Ÿ” Troubleshooting +## ๐Ÿšจ Troubleshooting ### Common Issues -**1. OAuth Login Fails** -- Check Google OAuth configuration -- Verify redirect URIs match exactly -- Ensure HTTPS is used in production - -**2. Database Connection Issues** -- Check if PostgreSQL container is healthy: `docker-compose ps` -- Verify database password in `.env` - -**3. Frontend Can't Reach Backend** -- Verify `VITE_API_URL` in `.env` matches your backend URL -- Check if backend is accessible: `curl http://localhost:3000/health` - -**4. Permission Denied Errors** -- Ensure Docker has proper permissions -- Check file ownership and permissions - -### Viewing Logs - +**OAuth Not Working**: ```bash -# View all logs -docker-compose logs +# Check Google OAuth configuration +docker-compose -f docker-compose.prod.yml logs backend | grep -i oauth -# View specific service logs -docker-compose logs backend -docker-compose logs frontend -docker-compose logs db +# Verify redirect URI matches exactly in Google Console +``` -# Follow logs in real-time -docker-compose logs -f backend +**Database Connection Error**: +```bash +# Check database status +docker-compose -f docker-compose.prod.yml ps db + +# View database logs +docker-compose -f docker-compose.prod.yml logs db +``` + +**Frontend Can't Connect to Backend**: +```bash +# Verify backend is running +curl http://localhost:3000/api/health + +# Check CORS configuration +docker-compose -f docker-compose.prod.yml logs backend | grep -i cors ``` ### Health Checks ```bash -# Check container status -docker-compose ps +# Check all service health +docker-compose -f docker-compose.prod.yml ps -# Check backend health -curl http://localhost:3000/health +# Test API health endpoint +curl http://localhost:3000/api/health -# Check frontend +# Test frontend curl http://localhost/ ``` -## ๐Ÿ”„ Updates - -To update to the latest version: - -```bash -# Pull latest images -docker-compose pull - -# Restart with new images -docker-compose up -d -``` - -## ๐Ÿ›‘ Stopping the Application - -```bash -# Stop all services -docker-compose down - -# Stop and remove volumes (โš ๏ธ This will delete all data) -docker-compose down -v -``` - -## ๐Ÿ“Š Monitoring - -### Container Health - -All containers include health checks: -- **Backend**: API endpoint health check -- **Database**: PostgreSQL connection check -- **Redis**: Redis ping check -- **Frontend**: Nginx status check - ### Logs -Logs are automatically rotated and can be viewed using Docker commands. +```bash +# View all logs +docker-compose -f docker-compose.prod.yml logs -## ๐Ÿ” Security Considerations +# Follow specific service logs +docker-compose -f docker-compose.prod.yml logs -f backend +docker-compose -f docker-compose.prod.yml logs -f frontend +docker-compose -f docker-compose.prod.yml logs -f db +``` -1. **Change default passwords** in `.env` -2. **Use HTTPS** in production -3. **Secure your server** with firewall rules -4. **Regular backups** of database volumes -5. **Keep Docker images updated** +## ๐Ÿ”„ Updates and Maintenance -## ๐Ÿ“ž Support +### Updating the Application -If you encounter issues: +```bash +# Pull latest changes +git pull origin main -1. Check the troubleshooting section above -2. Review container logs -3. Verify your configuration -4. Check GitHub issues for known problems +# Rebuild and restart +docker-compose -f docker-compose.prod.yml down +docker-compose -f docker-compose.prod.yml up -d --build +``` -## ๐ŸŽ‰ Success! +### Backup Database -Once deployed, you'll have a fully functional VIP Coordinator system with: -- โœ… Google OAuth authentication -- โœ… Mobile-friendly interface -- โœ… Real-time scheduling -- โœ… User management -- โœ… Automatic backups -- โœ… Health monitoring +```bash +# Create database backup +docker-compose -f docker-compose.prod.yml exec db pg_dump -U postgres vip_coordinator > backup.sql -The first user to log in will automatically become the system administrator. \ No newline at end of file +# Restore from backup +docker-compose -f docker-compose.prod.yml exec -T db psql -U postgres vip_coordinator < backup.sql +``` + +## ๐Ÿ“š Additional Resources + +- **API Documentation**: Available at `/api-docs.html` when running +- **User Roles**: Administrator, Coordinator, Driver +- **Flight Tracking**: Configure AviationStack API key in admin panel +- **Support**: Check GitHub issues for common problems + +## ๐Ÿ†˜ Getting Help + +1. Check this deployment guide +2. Review the troubleshooting section +3. Check Docker container logs +4. Verify environment configuration +5. Test with health check endpoints + +--- + +**VIP Coordinator** - Streamlined VIP logistics management with modern containerized deployment. \ No newline at end of file diff --git a/Makefile b/Makefile index 4a9f5d2..52529dd 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,74 @@ -.PHONY: dev build deploy +.PHONY: dev build deploy test test-backend test-frontend test-e2e test-coverage clean help +# Development dev: docker-compose -f docker-compose.dev.yml up --build +# Production build build: docker-compose -f docker-compose.prod.yml build +# Deploy to production deploy: docker-compose -f docker-compose.prod.yml up -d + +# Run all tests +test: + @bash scripts/test-runner.sh all + +# Run backend tests only +test-backend: + @bash scripts/test-runner.sh backend + +# Run frontend tests only +test-frontend: + @bash scripts/test-runner.sh frontend + +# Run E2E tests only +test-e2e: + @bash scripts/test-runner.sh e2e + +# Generate test coverage reports +test-coverage: + @bash scripts/test-runner.sh coverage + +# Database commands +db-setup: + docker-compose -f docker-compose.dev.yml run --rm backend npm run db:setup + +db-migrate: + docker-compose -f docker-compose.dev.yml run --rm backend npm run db:migrate + +db-seed: + docker-compose -f docker-compose.dev.yml run --rm backend npm run db:seed + +# Clean up Docker resources +clean: + docker-compose -f docker-compose.dev.yml down -v + docker-compose -f docker-compose.test.yml down -v + docker-compose -f docker-compose.prod.yml down -v + +# Show available commands +help: + @echo "VIP Coordinator - Available Commands:" + @echo "" + @echo "Development:" + @echo " make dev - Start development environment" + @echo " make build - Build production containers" + @echo " make deploy - Deploy to production" + @echo "" + @echo "Testing:" + @echo " make test - Run all tests" + @echo " make test-backend - Run backend tests only" + @echo " make test-frontend - Run frontend tests only" + @echo " make test-e2e - Run E2E tests only" + @echo " make test-coverage - Generate test coverage reports" + @echo "" + @echo "Database:" + @echo " make db-setup - Initialize database with schema and seed data" + @echo " make db-migrate - Run database migrations" + @echo " make db-seed - Seed database with test data" + @echo "" + @echo "Maintenance:" + @echo " make clean - Clean up all Docker resources" + @echo " make help - Show this help message" diff --git a/backend/package.json b/backend/package.json index 9b970bd..e3343d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,14 @@ "start": "node dist/index.js", "dev": "npx tsx src/index.ts", "build": "tsc", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "db:migrate": "tsx src/scripts/db-cli.ts migrate", + "db:migrate:create": "tsx src/scripts/db-cli.ts migrate:create", + "db:seed": "tsx src/scripts/db-cli.ts seed", + "db:seed:reset": "tsx src/scripts/db-cli.ts seed:reset", + "db:setup": "tsx src/scripts/db-cli.ts setup" }, "keywords": [ "vip", @@ -21,18 +28,25 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "google-auth-library": "^10.1.0", "jsonwebtoken": "^9.0.2", "pg": "^8.11.3", "redis": "^4.6.8", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@types/cors": "^2.8.13", "@types/express": "^4.17.17", + "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.2", "@types/node": "^20.5.0", "@types/pg": "^8.10.2", + "@types/supertest": "^2.0.16", "@types/uuid": "^9.0.2", + "jest": "^29.7.0", + "supertest": "^6.3.4", + "ts-jest": "^29.1.2", "ts-node": "^10.9.1", "ts-node-dev": "^2.0.0", "tsx": "^4.7.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index ccad26f..c460a3f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,868 +1,325 @@ -import express, { Express, Request, Response } from 'express'; -import dotenv from 'dotenv'; +import express from 'express'; import cors from 'cors'; +import dotenv from 'dotenv'; +// import authService from './services/authService'; // Replaced by simpleAuth import authRoutes, { requireAuth, requireRole } from './routes/simpleAuth'; -import flightService from './services/flightService'; -import driverConflictService from './services/driverConflictService'; -import scheduleValidationService from './services/scheduleValidationService'; -import FlightTrackingScheduler from './services/flightTrackingScheduler'; -import enhancedDataService from './services/enhancedDataService'; -import databaseService from './services/databaseService'; +import dataService from './services/unifiedDataService'; +import { validate, schemas } from './middleware/simpleValidation'; +import { errorHandler, notFoundHandler } from './middleware/errorHandler'; import jwtKeyManager from './services/jwtKeyManager'; // Initialize JWT Key Manager dotenv.config(); -const app: Express = express(); -const port: number = process.env.PORT ? parseInt(process.env.PORT) : 3000; +// Log environment variables status on startup +console.log('Environment variables loaded:'); +console.log('- GOOGLE_CLIENT_ID:', process.env.GOOGLE_CLIENT_ID ? 'Set' : 'Not set'); +console.log('- GOOGLE_CLIENT_SECRET:', process.env.GOOGLE_CLIENT_SECRET ? 'Set' : 'Not set'); +console.log('- GOOGLE_REDIRECT_URI:', process.env.GOOGLE_REDIRECT_URI || 'Not set'); + +const app = express(); +const port = process.env.PORT || 3000; // Middleware app.use(cors({ origin: [ process.env.FRONTEND_URL || 'http://localhost:5173', - 'http://localhost:5173', - 'http://localhost:3000', - 'http://localhost', // Frontend Docker container (local testing) - 'https://bsa.madeamess.online' // Production frontend domain (where users access the site) + 'https://bsa.madeamess.online' ], credentials: true })); app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// Simple JWT-based authentication - no passport needed - -// Authentication routes -app.use('/auth', authRoutes); - -// Temporary admin bypass route (remove after setup) -app.get('/admin-bypass', (req: Request, res: Response) => { - res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5173'}/admin?bypass=true`); -}); - -// Serve static files from public directory app.use(express.static('public')); -// Enhanced health check endpoint with authentication system status -app.get('/api/health', async (req: Request, res: Response) => { - try { - const timestamp = new Date().toISOString(); - - // Check JWT Key Manager status - const jwtStatus = jwtKeyManager.getStatus(); - - // Check environment variables - const envCheck = { - google_client_id: !!process.env.GOOGLE_CLIENT_ID, - google_client_secret: !!process.env.GOOGLE_CLIENT_SECRET, - google_redirect_uri: !!process.env.GOOGLE_REDIRECT_URI, - frontend_url: !!process.env.FRONTEND_URL, - database_url: !!process.env.DATABASE_URL, - admin_password: !!process.env.ADMIN_PASSWORD - }; - - // Check database connectivity - let databaseStatus = 'unknown'; - let userCount = 0; - try { - userCount = await databaseService.getUserCount(); - databaseStatus = 'connected'; - } catch (dbError) { - databaseStatus = 'disconnected'; - console.error('Health check - Database error:', dbError); - } - - // Overall system health - const isHealthy = databaseStatus === 'connected' && - jwtStatus.hasCurrentKey && - envCheck.google_client_id && - envCheck.google_client_secret; - - const healthData = { - status: isHealthy ? 'OK' : 'DEGRADED', - timestamp, - version: '1.0.0', - environment: process.env.NODE_ENV || 'development', - services: { - database: { - status: databaseStatus, - user_count: databaseStatus === 'connected' ? userCount : null - }, - authentication: { - jwt_key_manager: jwtStatus, - oauth_configured: envCheck.google_client_id && envCheck.google_client_secret, - environment_variables: envCheck - } - }, - uptime: process.uptime(), - memory: process.memoryUsage() - }; - - // Log health check for monitoring - console.log(`๐Ÿฅ Health Check [${timestamp}]:`, { - status: healthData.status, - database: databaseStatus, - jwt_keys: jwtStatus.hasCurrentKey, - oauth: envCheck.google_client_id && envCheck.google_client_secret - }); - - res.status(isHealthy ? 200 : 503).json(healthData); - - } catch (error) { - console.error('Health check error:', error); - res.status(500).json({ - status: 'ERROR', - timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : 'Unknown error' - }); - } -}); - -// Data is now persisted using dataService - no more in-memory storage! - -// Admin password - MUST be set via environment variable in production -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'CHANGE_ME_ADMIN_PASSWORD'; - -// Initialize flight tracking scheduler -const flightTracker = new FlightTrackingScheduler(flightService); - -// VIP routes (protected) -app.post('/api/vips', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - // Create a new VIP - const { - name, - organization, - department, // New: Office of Development or Admin - transportMode, - flightNumber, // Legacy single flight - flights, // New: array of flights - expectedArrival, - needsAirportPickup, - needsVenueTransport, - notes - } = req.body; - - const newVip = { - id: Date.now().toString(), // Simple ID generation - name, - organization, - department: department || 'Office of Development', // Default to Office of Development - transportMode: transportMode || 'flight', - // Support both legacy single flight and new multiple flights - flightNumber: transportMode === 'flight' && !flights ? flightNumber : undefined, - flights: transportMode === 'flight' && flights ? flights : undefined, - expectedArrival: transportMode === 'self-driving' ? expectedArrival : undefined, - arrivalTime: transportMode === 'flight' ? undefined : expectedArrival, // Legacy field for flight arrivals - needsAirportPickup: transportMode === 'flight' ? (needsAirportPickup !== false) : false, - needsVenueTransport: needsVenueTransport !== false, // Default to true - assignedDriverIds: [], - notes: notes || '', - schedule: [] - }; - - const savedVip = await enhancedDataService.addVip(newVip); - - // Add flights to tracking scheduler if applicable - if (savedVip.transportMode === 'flight' && savedVip.flights && savedVip.flights.length > 0) { - flightTracker.addVipFlights(savedVip.id, savedVip.name, savedVip.flights); - } - - res.status(201).json(savedVip); -}); - -app.get('/api/vips', requireAuth, async (req: Request, res: Response) => { - try { - // Fetch all VIPs - const vips = await enhancedDataService.getVips(); - res.json(vips); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch VIPs' }); - } -}); - -app.put('/api/vips/:id', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - // Update a VIP - const { id } = req.params; - const { - name, - organization, - department, // New: Office of Development or Admin - transportMode, - flightNumber, // Legacy single flight - flights, // New: array of flights - expectedArrival, - needsAirportPickup, - needsVenueTransport, - notes - } = req.body; - - try { - const updatedVip = { - name, - organization, - department: department || 'Office of Development', - transportMode: transportMode || 'flight', - // Support both legacy single flight and new multiple flights - flights: transportMode === 'flight' && flights ? flights : undefined, - expectedArrival: transportMode === 'self-driving' ? expectedArrival : undefined, - needsAirportPickup: transportMode === 'flight' ? (needsAirportPickup !== false) : false, - needsVenueTransport: needsVenueTransport !== false, - notes: notes || '' - }; - - const savedVip = await enhancedDataService.updateVip(id, updatedVip); - - if (!savedVip) { - return res.status(404).json({ error: 'VIP not found' }); - } - - // Update flight tracking if needed - if (savedVip.transportMode === 'flight') { - // Remove old flights - flightTracker.removeVipFlights(id); - - // Add new flights if any - if (savedVip.flights && savedVip.flights.length > 0) { - flightTracker.addVipFlights(savedVip.id, savedVip.name, savedVip.flights); - } - } - - res.json(savedVip); - } catch (error) { - res.status(500).json({ error: 'Failed to update VIP' }); - } -}); - -app.delete('/api/vips/:id', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - // Delete a VIP - const { id } = req.params; - - try { - const deletedVip = await enhancedDataService.deleteVip(id); - - if (!deletedVip) { - return res.status(404).json({ error: 'VIP not found' }); - } - - // Remove from flight tracking - flightTracker.removeVipFlights(id); - - res.json({ message: 'VIP deleted successfully', vip: deletedVip }); - } catch (error) { - res.status(500).json({ error: 'Failed to delete VIP' }); - } -}); - -// Driver routes (protected) -app.post('/api/drivers', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - // Create a new driver - const { name, phone, currentLocation, department } = req.body; - - const newDriver = { - id: Date.now().toString(), - name, - phone, - department: department || 'Office of Development', // Default to Office of Development - currentLocation: currentLocation || { lat: 0, lng: 0 }, - assignedVipIds: [] - }; - - try { - const savedDriver = await enhancedDataService.addDriver(newDriver); - res.status(201).json(savedDriver); - } catch (error) { - res.status(500).json({ error: 'Failed to create driver' }); - } -}); - -app.get('/api/drivers', requireAuth, async (req: Request, res: Response) => { - try { - // Fetch all drivers - const drivers = await enhancedDataService.getDrivers(); - res.json(drivers); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch drivers' }); - } -}); - -app.put('/api/drivers/:id', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - // Update a driver - const { id } = req.params; - const { name, phone, currentLocation, department } = req.body; - - try { - const updatedDriver = { - name, - phone, - department: department || 'Office of Development', - currentLocation: currentLocation || { lat: 0, lng: 0 } - }; - - const savedDriver = await enhancedDataService.updateDriver(id, updatedDriver); - - if (!savedDriver) { - return res.status(404).json({ error: 'Driver not found' }); - } - - res.json(savedDriver); - } catch (error) { - res.status(500).json({ error: 'Failed to update driver' }); - } -}); - -app.delete('/api/drivers/:id', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - // Delete a driver - const { id } = req.params; - - try { - const deletedDriver = await enhancedDataService.deleteDriver(id); - - if (!deletedDriver) { - return res.status(404).json({ error: 'Driver not found' }); - } - - res.json({ message: 'Driver deleted successfully', driver: deletedDriver }); - } catch (error) { - res.status(500).json({ error: 'Failed to delete driver' }); - } -}); - -// Enhanced flight tracking routes with date specificity -app.get('/api/flights/:flightNumber', async (req: Request, res: Response) => { - try { - const { flightNumber } = req.params; - const { date, departureAirport, arrivalAirport } = req.query; - - // Default to today if no date provided - const flightDate = (date as string) || new Date().toISOString().split('T')[0]; - - const flightData = await flightService.getFlightInfo({ - flightNumber, - date: flightDate, - departureAirport: departureAirport as string, - arrivalAirport: arrivalAirport as string - }); - - if (flightData) { - // Always return flight data for validation, even if date doesn't match - res.json(flightData); - } else { - // Only return 404 if the flight number itself is invalid - res.status(404).json({ error: 'Invalid flight number - this flight does not exist' }); - } - } catch (error) { - res.status(500).json({ error: 'Failed to fetch flight data' }); - } -}); - -// Start periodic updates for a flight -app.post('/api/flights/:flightNumber/track', async (req: Request, res: Response) => { - try { - const { flightNumber } = req.params; - const { date, intervalMinutes = 5 } = req.body; - - if (!date) { - return res.status(400).json({ error: 'Flight date is required' }); - } - - flightService.startPeriodicUpdates({ - flightNumber, - date - }, intervalMinutes); - - res.json({ message: `Started tracking ${flightNumber} on ${date}` }); - } catch (error) { - res.status(500).json({ error: 'Failed to start flight tracking' }); - } -}); - -// Stop periodic updates for a flight -app.delete('/api/flights/:flightNumber/track', async (req: Request, res: Response) => { - try { - const { flightNumber } = req.params; - const { date } = req.query; - - if (!date) { - return res.status(400).json({ error: 'Flight date is required' }); - } - - const key = `${flightNumber}_${date}`; - flightService.stopPeriodicUpdates(key); - - res.json({ message: `Stopped tracking ${flightNumber} on ${date}` }); - } catch (error) { - res.status(500).json({ error: 'Failed to stop flight tracking' }); - } -}); - -app.post('/api/flights/batch', async (req: Request, res: Response) => { - try { - const { flights } = req.body; - - if (!Array.isArray(flights)) { - return res.status(400).json({ error: 'flights must be an array of {flightNumber, date} objects' }); - } - - // Validate flight objects - for (const flight of flights) { - if (!flight.flightNumber || !flight.date) { - return res.status(400).json({ error: 'Each flight must have flightNumber and date' }); - } - } - - const flightData = await flightService.getMultipleFlights(flights); - res.json(flightData); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch flight data' }); - } -}); - -// Get flight tracking status -app.get('/api/flights/tracking/status', (req: Request, res: Response) => { - const status = flightTracker.getTrackingStatus(); - res.json(status); -}); - -// Schedule management routes (protected) -app.get('/api/vips/:vipId/schedule', requireAuth, async (req: Request, res: Response) => { - const { vipId } = req.params; - try { - const vipSchedule = await enhancedDataService.getSchedule(vipId); - res.json(vipSchedule); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch schedule' }); - } -}); - -app.post('/api/vips/:vipId/schedule', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - const { vipId } = req.params; - const { title, location, startTime, endTime, description, type, assignedDriverId } = req.body; - - // Validate the event - const validationErrors = scheduleValidationService.validateEvent({ - title: title || '', - location: location || '', - startTime: startTime || '', - endTime: endTime || '', - type: type || '' - }, false); - - const { critical, warnings } = scheduleValidationService.categorizeErrors(validationErrors); - - // Return validation errors if any critical errors exist - if (critical.length > 0) { - return res.status(400).json({ - error: 'Validation failed', - validationErrors: critical, - warnings: warnings, - message: scheduleValidationService.getErrorSummary(critical) - }); - } - - const newEvent = { - id: Date.now().toString(), - title, - location, - startTime, - endTime, - description: description || '', - assignedDriverId: assignedDriverId || '', - status: 'scheduled', - type - }; - - try { - const savedEvent = await enhancedDataService.addScheduleEvent(vipId, newEvent); - - // Include warnings in the response if any - const response: any = { ...savedEvent }; - if (warnings.length > 0) { - response.warnings = warnings; - } - - res.status(201).json(response); - } catch (error) { - res.status(500).json({ error: 'Failed to create schedule event' }); - } -}); - -app.put('/api/vips/:vipId/schedule/:eventId', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - const { vipId, eventId } = req.params; - const { title, location, startTime, endTime, description, type, assignedDriverId, status } = req.body; - - // Validate the updated event (with edit flag for grace period) - const validationErrors = scheduleValidationService.validateEvent({ - title: title || '', - location: location || '', - startTime: startTime || '', - endTime: endTime || '', - type: type || '' - }, true); - - const { critical, warnings } = scheduleValidationService.categorizeErrors(validationErrors); - - // Return validation errors if any critical errors exist - if (critical.length > 0) { - return res.status(400).json({ - error: 'Validation failed', - validationErrors: critical, - warnings: warnings, - message: scheduleValidationService.getErrorSummary(critical) - }); - } - - const updatedEvent = { - id: eventId, - title, - location, - startTime, - endTime, - description: description || '', - assignedDriverId: assignedDriverId || '', - type, - status: status || 'scheduled' - }; - - try { - const savedEvent = await enhancedDataService.updateScheduleEvent(vipId, eventId, updatedEvent); - - if (!savedEvent) { - return res.status(404).json({ error: 'Event not found' }); - } - - // Include warnings in the response if any - const response: any = { ...savedEvent }; - if (warnings.length > 0) { - response.warnings = warnings; - } - - res.json(response); - } catch (error) { - res.status(500).json({ error: 'Failed to update schedule event' }); - } -}); - -app.patch('/api/vips/:vipId/schedule/:eventId/status', requireAuth, async (req: Request, res: Response) => { - const { vipId, eventId } = req.params; - const { status } = req.body; - - try { - const currentSchedule = await enhancedDataService.getSchedule(vipId); - const currentEvent = currentSchedule.find((event: any) => event.id === eventId); - - if (!currentEvent) { - return res.status(404).json({ error: 'Event not found' }); - } - - const updatedEvent = { ...currentEvent, status }; - const savedEvent = await enhancedDataService.updateScheduleEvent(vipId, eventId, updatedEvent); - - if (!savedEvent) { - return res.status(404).json({ error: 'Event not found' }); - } - - res.json(savedEvent); - } catch (error) { - res.status(500).json({ error: 'Failed to update event status' }); - } -}); - -app.delete('/api/vips/:vipId/schedule/:eventId', requireAuth, requireRole(['coordinator', 'administrator']), async (req: Request, res: Response) => { - const { vipId, eventId } = req.params; - - try { - const deletedEvent = await enhancedDataService.deleteScheduleEvent(vipId, eventId); - - if (!deletedEvent) { - return res.status(404).json({ error: 'Event not found' }); - } - - res.json({ message: 'Event deleted successfully', event: deletedEvent }); - } catch (error) { - res.status(500).json({ error: 'Failed to delete schedule event' }); - } -}); - -// Driver availability and conflict checking (protected) -app.post('/api/drivers/availability', requireAuth, async (req: Request, res: Response) => { - const { startTime, endTime, location } = req.body; - - if (!startTime || !endTime) { - return res.status(400).json({ error: 'startTime and endTime are required' }); - } - - try { - const allSchedules = await enhancedDataService.getAllSchedules(); - const drivers = await enhancedDataService.getDrivers(); - - const availability = driverConflictService.getDriverAvailability( - { startTime, endTime, location: location || '' }, - allSchedules as any, - drivers - ); - - res.json(availability); - } catch (error) { - res.status(500).json({ error: 'Failed to check driver availability' }); - } -}); - -// Check conflicts for specific driver assignment (protected) -app.post('/api/drivers/:driverId/conflicts', requireAuth, async (req: Request, res: Response) => { - const { driverId } = req.params; - const { startTime, endTime, location } = req.body; - - if (!startTime || !endTime) { - return res.status(400).json({ error: 'startTime and endTime are required' }); - } - - try { - const allSchedules = await enhancedDataService.getAllSchedules(); - const drivers = await enhancedDataService.getDrivers(); - - const conflicts = driverConflictService.checkDriverConflicts( - driverId, - { startTime, endTime, location: location || '' }, - allSchedules as any, - drivers - ); - - res.json({ conflicts }); - } catch (error) { - res.status(500).json({ error: 'Failed to check driver conflicts' }); - } -}); - -// Get driver's complete schedule (protected) -app.get('/api/drivers/:driverId/schedule', requireAuth, async (req: Request, res: Response) => { - const { driverId } = req.params; - - try { - const drivers = await enhancedDataService.getDrivers(); - const driver = drivers.find((d: any) => d.id === driverId); - if (!driver) { - return res.status(404).json({ error: 'Driver not found' }); - } - - // Get all events assigned to this driver across all VIPs - const driverSchedule: any[] = []; - const allSchedules = await enhancedDataService.getAllSchedules(); - const vips = await enhancedDataService.getVips(); - - Object.entries(allSchedules).forEach(([vipId, events]: [string, any]) => { - events.forEach((event: any) => { - if (event.assignedDriverId === driverId) { - // Get VIP name - const vip = vips.find((v: any) => v.id === vipId); - driverSchedule.push({ - ...event, - vipId, - vipName: vip ? vip.name : 'Unknown VIP' - }); - } - }); - }); - - // Sort by start time - driverSchedule.sort((a, b) => - new Date(a.startTime).getTime() - new Date(b.startTime).getTime() - ); - - res.json({ - driver: { - id: driver.id, - name: driver.name, - phone: driver.phone, - department: driver.department - }, - schedule: driverSchedule - }); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch driver schedule' }); - } -}); - -// Admin routes -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(); - - // Return settings but mask API keys for display only - // IMPORTANT: Don't return the actual keys, just indicate they exist - const maskedSettings = { - apiKeys: { - 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 - }; - - res.json(maskedSettings); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch admin settings' }); - } -}); - -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(); - - // Update API keys (only if provided and not masked) - if (apiKeys) { - if (apiKeys.aviationStackKey && !apiKeys.aviationStackKey.startsWith('***')) { - currentSettings.apiKeys.aviationStackKey = apiKeys.aviationStackKey; - // Update the environment variable for the flight service - process.env.AVIATIONSTACK_API_KEY = apiKeys.aviationStackKey; - } - if (apiKeys.googleMapsKey && !apiKeys.googleMapsKey.startsWith('***')) { - currentSettings.apiKeys.googleMapsKey = apiKeys.googleMapsKey; - } - if (apiKeys.twilioKey && !apiKeys.twilioKey.startsWith('***')) { - currentSettings.apiKeys.twilioKey = apiKeys.twilioKey; - } - 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.googleClientSecret && !apiKeys.googleClientSecret.startsWith('***')) { - currentSettings.apiKeys.googleClientSecret = apiKeys.googleClientSecret; - // Update the environment variable for Google OAuth - process.env.GOOGLE_CLIENT_SECRET = apiKeys.googleClientSecret; - } - } - - // Update system settings - if (systemSettings) { - currentSettings.systemSettings = { ...currentSettings.systemSettings, ...systemSettings }; - } - - // Save the updated settings - await enhancedDataService.updateAdminSettings(currentSettings); - - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: 'Failed to update admin settings' }); - } -}); - -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; - - try { - switch (apiType) { - case 'aviationStackKey': - // Test AviationStack API - const testUrl = `http://api.aviationstack.com/v1/flights?access_key=${apiKey}&limit=1`; - const response = await fetch(testUrl); - - if (response.ok) { - const data: any = await response.json(); - if (data.error) { - res.status(400).json({ error: data.error.message || 'Invalid API key' }); - } else { - res.json({ success: true, message: 'API key is valid!' }); - } - } else { - res.status(400).json({ error: 'Failed to validate API key' }); - } - break; - - case 'googleMapsKey': - res.json({ success: true, message: 'Google Maps API testing not yet implemented' }); - break; - - case 'twilioKey': - res.json({ success: true, message: 'Twilio API testing not yet implemented' }); - break; - - default: - res.status(400).json({ error: 'Unknown API type' }); - } - } catch (error) { - res.status(500).json({ error: 'Failed to test API connection' }); - } -}); - -// JWT Key Management endpoints (admin only) -app.get('/api/admin/jwt-status', requireAuth, requireRole(['administrator']), (req: Request, res: Response) => { - const jwtKeyManager = require('./services/jwtKeyManager').default; - const status = jwtKeyManager.getStatus(); - - res.json({ - keyRotationEnabled: true, - rotationInterval: '24 hours', - gracePeriod: '24 hours', - ...status, - message: 'JWT keys are automatically rotated every 24 hours for enhanced security' +// Health check +app.get('/api/health', (req, res) => { + res.json({ + status: 'OK', + timestamp: new Date().toISOString(), + version: '2.0.0' // Simplified version }); }); -app.post('/api/admin/jwt-rotate', requireAuth, requireRole(['administrator']), (req: Request, res: Response) => { - const jwtKeyManager = require('./services/jwtKeyManager').default; - +// Auth routes - using simpleAuth with JWT Key Manager +app.use('/auth', authRoutes); + +// OLD AUTH ROUTES - COMMENTED OUT +/* +app.get('/auth/setup', async (req, res) => { try { - jwtKeyManager.forceRotation(); + // Check if any users exist in the system + const userCount = await dataService.getUserCount(); res.json({ - success: true, - message: 'JWT key rotation triggered successfully. New tokens will use the new key.' + needsSetup: userCount === 0, + hasUsers: userCount > 0 }); } catch (error) { - res.status(500).json({ error: 'Failed to rotate JWT keys' }); + console.error('Error in /auth/setup:', error); + res.status(500).json({ error: 'Failed to check setup status' }); } }); -// Initialize database and start server -async function startServer() { - try { - // Initialize database schema and migrate data - await databaseService.initializeDatabase(); - console.log('โœ… Database initialization completed'); - - // 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`); - }); - } catch (error) { - console.error('โŒ Failed to start server:', error); - process.exit(1); - } -} +app.get('/auth/google', (req, res) => { + res.redirect('/auth/google'); +}); -startServer(); +app.get('/auth/google/url', (req, res) => { + try { + // Return the Google OAuth URL as JSON for the frontend + const url = '/auth/google'; + res.json({ url }); + } catch (error) { + console.error('Error generating Google Auth URL:', error); + res.status(500).json({ + error: 'Google OAuth configuration error', + message: (error as Error).message + }); + } +}); + +app.post('/auth/google/callback', async (req, res) => { + try { + const { code } = req.body; + // Handled by simpleAuth routes + res.redirect('/auth/google/exchange'); + } catch (error) { + res.status(400).json({ error: 'Authentication failed' }); + } +}); + +app.post('/auth/google/exchange', async (req, res) => { + try { + const { code } = req.body; + // Handled by simpleAuth routes + res.redirect('/auth/google/exchange'); + } catch (error) { + res.status(400).json({ error: 'Authentication failed' }); + } +}); + +app.post('/auth/google/verify', async (req, res) => { + try { + const { credential } = req.body; + // Handled by simpleAuth routes + res.status(400).json({ error: 'Use /auth/google/exchange instead' }); + } catch (error) { + console.error('Google token verification error:', error); + res.status(400).json({ error: 'Authentication failed' }); + } +}); + +app.get('/auth/me', requireAuth, (req: any, res) => { + res.json(req.user); +}); + +app.post('/auth/logout', (req, res) => { + res.json({ message: 'Logged out successfully' }); +}); +*/ + +// VIP routes +app.get('/api/vips', async (req, res, next) => { + try { + const vips = await dataService.getVips(); + res.json(vips); + } catch (error) { + next(error); + } +}); + +app.get('/api/vips/:id', async (req, res, next) => { + try { + const vip = await dataService.getVipById(req.params.id); + if (!vip) return res.status(404).json({ error: 'VIP not found' }); + res.json(vip); + } catch (error) { + next(error); + } +}); + +app.post('/api/vips', + requireAuth, + requireRole(['coordinator', 'administrator']), + validate(schemas.createVip), + async (req, res, next) => { + try { + const vip = await dataService.createVip(req.body); + res.status(201).json(vip); + } catch (error) { + next(error); + } + } +); + +app.put('/api/vips/:id', + requireAuth, + requireRole(['coordinator', 'administrator']), + validate(schemas.updateVip), + async (req, res, next) => { + try { + const vip = await dataService.updateVip(req.params.id, req.body); + if (!vip) return res.status(404).json({ error: 'VIP not found' }); + res.json(vip); + } catch (error) { + next(error); + } + } +); + +app.delete('/api/vips/:id', + requireAuth, + requireRole(['coordinator', 'administrator']), + async (req, res, next) => { + try { + const vip = await dataService.deleteVip(req.params.id); + if (!vip) return res.status(404).json({ error: 'VIP not found' }); + res.json({ message: 'VIP deleted successfully' }); + } catch (error) { + next(error); + } + } +); + +// Driver routes +app.get('/api/drivers', async (req, res, next) => { + try { + const drivers = await dataService.getDrivers(); + res.json(drivers); + } catch (error) { + next(error); + } +}); + +app.post('/api/drivers', + requireAuth, + requireRole(['coordinator', 'administrator']), + validate(schemas.createDriver), + async (req, res, next) => { + try { + const driver = await dataService.createDriver(req.body); + res.status(201).json(driver); + } catch (error) { + next(error); + } + } +); + +app.put('/api/drivers/:id', + requireAuth, + requireRole(['coordinator', 'administrator']), + validate(schemas.updateDriver), + async (req, res, next) => { + try { + const driver = await dataService.updateDriver(req.params.id, req.body); + if (!driver) return res.status(404).json({ error: 'Driver not found' }); + res.json(driver); + } catch (error) { + next(error); + } + } +); + +app.delete('/api/drivers/:id', + requireAuth, + requireRole(['coordinator', 'administrator']), + async (req, res, next) => { + try { + const driver = await dataService.deleteDriver(req.params.id); + if (!driver) return res.status(404).json({ error: 'Driver not found' }); + res.json({ message: 'Driver deleted successfully' }); + } catch (error) { + next(error); + } + } +); + +// Schedule routes +app.get('/api/vips/:vipId/schedule', requireAuth, async (req, res, next) => { + try { + const schedule = await dataService.getScheduleByVipId(req.params.vipId); + res.json(schedule); + } catch (error) { + next(error); + } +}); + +app.post('/api/vips/:vipId/schedule', + requireAuth, + requireRole(['coordinator', 'administrator']), + validate(schemas.createScheduleEvent), + async (req, res, next) => { + try { + const event = await dataService.createScheduleEvent(req.params.vipId, req.body); + res.status(201).json(event); + } catch (error) { + next(error); + } + } +); + +app.put('/api/vips/:vipId/schedule/:eventId', + requireAuth, + requireRole(['coordinator', 'administrator']), + validate(schemas.updateScheduleEvent), + async (req, res, next) => { + try { + const event = await dataService.updateScheduleEvent(req.params.eventId, req.body); + if (!event) return res.status(404).json({ error: 'Event not found' }); + res.json(event); + } catch (error) { + next(error); + } + } +); + +app.delete('/api/vips/:vipId/schedule/:eventId', + requireAuth, + requireRole(['coordinator', 'administrator']), + async (req, res, next) => { + try { + const event = await dataService.deleteScheduleEvent(req.params.eventId); + if (!event) return res.status(404).json({ error: 'Event not found' }); + res.json({ message: 'Event deleted successfully' }); + } catch (error) { + next(error); + } + } +); + +// Admin routes (simplified) +app.get('/api/admin/settings', + requireAuth, + requireRole(['administrator']), + async (req, res, next) => { + try { + const settings = await dataService.getAdminSettings(); + res.json(settings); + } catch (error) { + next(error); + } + } +); + +app.post('/api/admin/settings', + requireAuth, + requireRole(['administrator']), + async (req, res, next) => { + try { + const { key, value } = req.body; + await dataService.updateAdminSetting(key, value); + res.json({ message: 'Setting updated successfully' }); + } catch (error) { + next(error); + } + } +); + +// Error handling +app.use(notFoundHandler); +app.use(errorHandler); + +// Start server +app.listen(port, () => { + console.log(`๐Ÿš€ Server running on port ${port}`); + console.log(`๐Ÿฅ Health check: http://localhost:${port}/api/health`); + console.log(`๐Ÿ“š API docs: http://localhost:${port}/api-docs.html`); +}); \ No newline at end of file diff --git a/backend/src/routes/simpleAuth.ts b/backend/src/routes/simpleAuth.ts index a947b21..082760e 100644 --- a/backend/src/routes/simpleAuth.ts +++ b/backend/src/routes/simpleAuth.ts @@ -12,7 +12,7 @@ import databaseService from '../services/databaseService'; const router = express.Router(); // Enhanced logging for production debugging -function logAuthEvent(event: string, details: any = {}) { +function logAuthEvent(event: string, details: Record = {}) { const timestamp = new Date().toISOString(); console.log(`๐Ÿ” [AUTH ${timestamp}] ${event}:`, JSON.stringify(details, null, 2)); } @@ -277,13 +277,13 @@ router.get('/google/callback', async (req: Request, res: Response) => { if (!user) { // Determine role - first user becomes admin, others need approval - const approvedUserCount = await databaseService.getApprovedUserCount(); - const role = approvedUserCount === 0 ? 'administrator' : 'coordinator'; + const isFirstUser = await databaseService.isFirstUser(); + const role = isFirstUser ? 'administrator' : 'coordinator'; logAuthEvent('USER_CREATION', { email: googleUser.email, role, - is_first_user: approvedUserCount === 0 + is_first_user: isFirstUser }); user = await databaseService.createUser({ @@ -292,13 +292,12 @@ router.get('/google/callback', async (req: Request, res: Response) => { email: googleUser.email, name: googleUser.name, profile_picture_url: googleUser.picture, - role + role, + status: isFirstUser ? 'active' : 'pending' }); - // Auto-approve first admin, others need approval - if (approvedUserCount === 0) { - await databaseService.updateUserApprovalStatus(googleUser.email, 'approved'); - user.approval_status = 'approved'; + // Log the user creation + if (isFirstUser) { logAuthEvent('FIRST_ADMIN_CREATED', { email: googleUser.email }); } else { logAuthEvent('USER_PENDING_APPROVAL', { email: googleUser.email }); @@ -314,9 +313,9 @@ router.get('/google/callback', async (req: Request, res: Response) => { }); } - // Check if user is approved - if (user.approval_status !== 'approved') { - logAuthEvent('USER_NOT_APPROVED', { email: user.email, status: user.approval_status }); + // Check if user is approved (admins are always approved) + if (user.role !== 'administrator' && user.status === 'pending') { + logAuthEvent('USER_NOT_APPROVED', { email: user.email, status: user.status }); return res.redirect(`${frontendUrl}?error=pending_approval&message=Your account is pending administrator approval`); } @@ -365,8 +364,8 @@ router.post('/google/exchange', async (req: Request, res: Response) => { if (!user) { // Determine role - first user becomes admin - const userCount = await databaseService.getUserCount(); - const role = userCount === 0 ? 'administrator' : 'coordinator'; + const isFirstUser = await databaseService.isFirstUser(); + const role = isFirstUser ? 'administrator' : 'coordinator'; user = await databaseService.createUser({ id: googleUser.id, @@ -374,14 +373,30 @@ router.post('/google/exchange', async (req: Request, res: Response) => { email: googleUser.email, name: googleUser.name, profile_picture_url: googleUser.picture, - role + role, + status: isFirstUser ? 'active' : 'pending' }); + + // Log the user creation + if (isFirstUser) { + console.log(`โœ… First admin created and auto-approved: ${user.name} (${user.email})`); + } else { + console.log(`โœ… User created (pending approval): ${user.name} (${user.email}) as ${user.role}`); + } } else { // Update last sign in await databaseService.updateUserLastSignIn(googleUser.email); console.log(`โœ… User logged in: ${user.name} (${user.email})`); } + // Check if user is approved (admins are always approved) + if (user.role !== 'administrator' && user.status === 'pending') { + return res.status(403).json({ + error: 'pending_approval', + message: 'Your account is pending administrator approval' + }); + } + // Generate JWT token const token = generateToken(user); @@ -393,7 +408,8 @@ router.post('/google/exchange', async (req: Request, res: Response) => { email: user.email, name: user.name, picture: user.profile_picture_url, - role: user.role + role: user.role, + status: user.status } }); @@ -420,6 +436,115 @@ router.post('/logout', (req: Request, res: Response) => { res.json({ message: 'Logged out successfully' }); }); +// Verify Google credential (from Google Identity Services) +router.post('/google/verify', async (req: Request, res: Response) => { + const { credential } = req.body; + + if (!credential) { + return res.status(400).json({ error: 'Credential is required' }); + } + + try { + // Decode the JWT credential from Google + const parts = credential.split('.'); + if (parts.length !== 3) { + return res.status(400).json({ error: 'Invalid credential format' }); + } + + // Decode the payload (base64) + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + if (!payload.email || !payload.email_verified) { + return res.status(400).json({ error: 'Invalid or unverified email' }); + } + + // Create Google user object + const googleUser = { + id: payload.sub, + email: payload.email, + name: payload.name || payload.email, + picture: payload.picture, + verified_email: payload.email_verified + }; + + logAuthEvent('GOOGLE_CREDENTIAL_VERIFIED', { + email: googleUser.email, + name: googleUser.name + }); + + // Check if user exists or create new user + let user = await databaseService.getUserByEmail(googleUser.email); + + if (!user) { + // Determine role - first user becomes admin + const isFirstUser = await databaseService.isFirstUser(); + const role = isFirstUser ? '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, + status: isFirstUser ? 'active' : 'pending' + }); + + // Log the user creation + if (isFirstUser) { + logAuthEvent('FIRST_ADMIN_CREATED', { email: googleUser.email }); + } else { + logAuthEvent('USER_PENDING_APPROVAL', { email: googleUser.email }); + } + } else { + // Update last sign in + await databaseService.updateUserLastSignIn(googleUser.email); + logAuthEvent('USER_LOGIN', { + email: user.email, + name: user.name, + role: user.role, + status: user.status + }); + } + + // Check if user is approved (admins are always approved) + if (user.role !== 'administrator' && user.status === 'pending') { + return res.status(403).json({ + error: 'pending_approval', + message: 'Your account is pending administrator approval', + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + status: user.status + }, + token: generateToken(user) // Still give them a token so they can check status + }); + } + + // 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, + status: user.status + } + }); + + } catch (error) { + console.error('Error verifying Google credential:', error); + res.status(500).json({ error: 'Failed to verify credential' }); + } +}); + // Get auth status router.get('/status', (req: Request, res: Response) => { const authHeader = req.headers.authorization; @@ -610,4 +735,143 @@ router.patch('/users/:email/approval', requireAuth, requireRole(['administrator' } }); +// Complete user onboarding +router.post('/users/complete-onboarding', requireAuth, async (req: Request, res: Response) => { + try { + const userEmail = req.user?.email; + if (!userEmail) { + return res.status(401).json({ error: 'User not authenticated' }); + } + + const { onboardingData, phone, organization } = req.body; + + const updatedUser = await databaseService.completeUserOnboarding(userEmail, { + ...onboardingData, + phone, + organization + }); + + res.json({ message: 'Onboarding completed successfully', user: updatedUser }); + } catch (error) { + console.error('Failed to complete onboarding:', error); + res.status(500).json({ error: 'Failed to complete onboarding' }); + } +}); + +// Get current user with full details +router.get('/users/me', requireAuth, async (req: Request, res: Response) => { + try { + const userEmail = req.user?.email; + if (!userEmail) { + return res.status(401).json({ error: 'User not authenticated' }); + } + + const user = await databaseService.getUserByEmail(userEmail); + res.json(user); + } catch (error) { + console.error('Failed to get user details:', error); + res.status(500).json({ error: 'Failed to get user details' }); + } +}); + +// Approve user (by email, not ID) +router.post('/users/:email/approve', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => { + try { + const { email } = req.params; + const { role } = req.body; + const approvedBy = req.user?.email || ''; + + const updatedUser = await databaseService.approveUser(email, approvedBy, role); + + if (!updatedUser) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ message: 'User approved successfully', user: updatedUser }); + } catch (error) { + console.error('Failed to approve user:', error); + res.status(500).json({ error: 'Failed to approve user' }); + } +}); + +// Reject user +router.post('/users/:email/reject', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => { + try { + const { email } = req.params; + const { reason } = req.body; + const rejectedBy = req.user?.email || ''; + + const updatedUser = await databaseService.rejectUser(email, rejectedBy, reason); + + if (!updatedUser) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ message: 'User rejected', user: updatedUser }); + } catch (error) { + console.error('Failed to reject user:', error); + res.status(500).json({ error: 'Failed to reject user' }); + } +}); + +// Deactivate user +router.post('/users/:email/deactivate', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => { + try { + const { email } = req.params; + const deactivatedBy = req.user?.email || ''; + + const updatedUser = await databaseService.deactivateUser(email, deactivatedBy); + + if (!updatedUser) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ message: 'User deactivated', user: updatedUser }); + } catch (error) { + console.error('Failed to deactivate user:', error); + res.status(500).json({ error: 'Failed to deactivate user' }); + } +}); + +// Reactivate user +router.post('/users/:email/reactivate', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => { + try { + const { email } = req.params; + const reactivatedBy = req.user?.email || ''; + + const updatedUser = await databaseService.reactivateUser(email, reactivatedBy); + + if (!updatedUser) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ message: 'User reactivated', user: updatedUser }); + } catch (error) { + console.error('Failed to reactivate user:', error); + res.status(500).json({ error: 'Failed to reactivate user' }); + } +}); + +// Update user role +router.put('/users/:email/role', requireAuth, requireRole(['administrator']), async (req: Request, res: Response) => { + try { + const { email } = req.params; + const { role } = req.body; + + const updatedUser = await databaseService.updateUserRole(email, role); + + if (!updatedUser) { + return res.status(404).json({ error: 'User not found' }); + } + + // Log audit + await databaseService.createAuditLog('role_changed', email, req.user?.email || '', { newRole: role }); + + res.json({ message: 'User role updated', user: updatedUser }); + } catch (error) { + console.error('Failed to update user role:', error); + res.status(500).json({ error: 'Failed to update user role' }); + } +}); + export default router; diff --git a/backend/src/services/databaseService.ts b/backend/src/services/databaseService.ts index ddb5434..e9dbc09 100644 --- a/backend/src/services/databaseService.ts +++ b/backend/src/services/databaseService.ts @@ -1,550 +1,332 @@ import { Pool, PoolClient } from 'pg'; import { createClient, RedisClientType } from 'redis'; -class DatabaseService { - private pool: Pool; - private redis: RedisClientType; +// Import the existing backup service +import backupDatabaseService from './backup-services/databaseService'; + +// Extend the backup service with new user management methods +class EnhancedDatabaseService { + private backupService: typeof backupDatabaseService; constructor() { - this.pool = new Pool({ - connectionString: process.env.DATABASE_URL, - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false - }); - - // Initialize Redis connection - this.redis = createClient({ - socket: { - host: process.env.REDIS_HOST || 'redis', - port: parseInt(process.env.REDIS_PORT || '6379') - } - }); - - this.redis.on('error', (err) => { - console.error('โŒ Redis connection error:', err); - }); - - // Test connections on startup - this.testConnection(); - this.testRedisConnection(); - } - - private async testConnection(): Promise { - try { - const client = await this.pool.connect(); - console.log('โœ… Connected to PostgreSQL database'); - client.release(); - } catch (error) { - console.error('โŒ Failed to connect to PostgreSQL database:', error); - } - } - - private async testRedisConnection(): Promise { - try { - if (!this.redis.isOpen) { - await this.redis.connect(); - } - await this.redis.ping(); - console.log('โœ… Connected to Redis'); - } catch (error) { - console.error('โŒ Failed to connect to Redis:', error); - } + this.backupService = backupDatabaseService; } + // Delegate all existing methods to backup service async query(text: string, params?: any[]): Promise { - const client = await this.pool.connect(); - try { - const result = await client.query(text, params); - return result; - } finally { - client.release(); - } + return this.backupService.query(text, params); } async getClient(): Promise { - return await this.pool.connect(); + return this.backupService.getClient(); } async close(): Promise { - await this.pool.end(); - if (this.redis.isOpen) { - await this.redis.disconnect(); - } + return this.backupService.close(); } - // Initialize database tables async initializeTables(): Promise { - try { - // Create users table (matching the actual schema) - await this.query(` - CREATE TABLE IF NOT EXISTS users ( - id VARCHAR(255) PRIMARY KEY, - google_id VARCHAR(255) UNIQUE NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - name VARCHAR(255) NOT NULL, - role VARCHAR(50) NOT NULL CHECK (role IN ('driver', 'coordinator', 'administrator')), - profile_picture_url TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_login TIMESTAMP, - is_active BOOLEAN DEFAULT true, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - approval_status VARCHAR(20) DEFAULT 'pending' CHECK (approval_status IN ('pending', 'approved', 'denied')) - ) - `); - - // Add approval_status column if it doesn't exist (migration for existing databases) - await this.query(` - ALTER TABLE users - ADD COLUMN IF NOT EXISTS approval_status VARCHAR(20) DEFAULT 'pending' CHECK (approval_status IN ('pending', 'approved', 'denied')) - `); - - // Create indexes - await this.query(` - CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id) - `); - - await this.query(` - CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) - `); - - await this.query(` - CREATE INDEX IF NOT EXISTS idx_users_role ON users(role) - `); - - console.log('โœ… Database tables initialized successfully'); - } catch (error) { - console.error('โŒ Failed to initialize database tables:', error); - throw error; - } + return this.backupService.initializeTables(); } - // User management methods - async createUser(user: { - id: string; - google_id: string; - email: string; - name: string; - profile_picture_url?: string; - role: string; - }): Promise { - const query = ` - INSERT INTO users (id, google_id, email, name, profile_picture_url, role, last_login) - VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP) - RETURNING * - `; - - const values = [ - user.id, - user.google_id, - user.email, - user.name, - user.profile_picture_url || null, - user.role - ]; - - const result = await this.query(query, values); - console.log(`๐Ÿ‘ค Created user: ${user.name} (${user.email}) as ${user.role}`); - return result.rows[0]; + // User methods from backup service + async createUser(user: any): Promise { + return this.backupService.createUser(user); } async getUserByEmail(email: string): Promise { - const query = 'SELECT * FROM users WHERE email = $1'; - const result = await this.query(query, [email]); - return result.rows[0] || null; + return this.backupService.getUserByEmail(email); } async getUserById(id: string): Promise { - const query = 'SELECT * FROM users WHERE id = $1'; - const result = await this.query(query, [id]); - return result.rows[0] || null; - } - - async getAllUsers(): Promise { - const query = 'SELECT * FROM users ORDER BY created_at ASC'; - const result = await this.query(query); - return result.rows; + return this.backupService.getUserById(id); } async updateUserRole(email: string, role: string): Promise { - const query = ` - UPDATE users - SET role = $1, updated_at = CURRENT_TIMESTAMP - WHERE email = $2 - RETURNING * - `; - - const result = await this.query(query, [role, email]); - if (result.rows[0]) { - console.log(`๐Ÿ‘ค Updated user role: ${result.rows[0].name} (${email}) -> ${role}`); - } - return result.rows[0] || null; + return this.backupService.updateUserRole(email, role); } async updateUserLastSignIn(email: string): Promise { - const query = ` - UPDATE users - SET last_login = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE email = $1 - RETURNING * - `; - - const result = await this.query(query, [email]); - return result.rows[0] || null; - } - - async deleteUser(email: string): Promise { - const query = 'DELETE FROM users WHERE email = $1 RETURNING *'; - const result = await this.query(query, [email]); - if (result.rows[0]) { - console.log(`๐Ÿ‘ค Deleted user: ${result.rows[0].name} (${email})`); - } - return result.rows[0] || null; + return this.backupService.updateUserLastSignIn(email); } async getUserCount(): Promise { - const query = 'SELECT COUNT(*) as count FROM users'; + return this.backupService.getUserCount(); + } + + async updateUserApprovalStatus(email: string, status: 'pending' | 'approved' | 'denied'): Promise { + return this.backupService.updateUserApprovalStatus(email, status); + } + + async getApprovedUserCount(): Promise { + return this.backupService.getApprovedUserCount(); + } + + async getAllUsers(): Promise { + return this.backupService.getAllUsers(); + } + + async deleteUser(email: string): Promise { + return this.backupService.deleteUser(email); + } + + async getPendingUsers(): Promise { + return this.backupService.getPendingUsers(); + } + + // NEW: Enhanced user management methods + async completeUserOnboarding(email: string, onboardingData: any): Promise { + const query = ` + UPDATE users + SET phone = $1, + organization = $2, + onboarding_data = $3, + updated_at = CURRENT_TIMESTAMP + WHERE email = $4 + RETURNING * + `; + + const result = await this.query(query, [ + onboardingData.phone, + onboardingData.organization, + JSON.stringify(onboardingData), + email + ]); + + return result.rows[0] || null; + } + + async approveUser(userEmail: string, approvedBy: string, newRole?: string): Promise { + const query = ` + UPDATE users + SET status = 'active', + approval_status = 'approved', + approved_by = $1, + approved_at = CURRENT_TIMESTAMP, + role = COALESCE($2, role), + updated_at = CURRENT_TIMESTAMP + WHERE email = $3 + RETURNING * + `; + + const result = await this.query(query, [approvedBy, newRole, userEmail]); + + // Log audit + if (result.rows[0]) { + await this.createAuditLog('user_approved', userEmail, approvedBy, { newRole }); + } + + return result.rows[0] || null; + } + + async rejectUser(userEmail: string, rejectedBy: string, reason?: string): Promise { + const query = ` + UPDATE users + SET status = 'deactivated', + approval_status = 'denied', + rejected_by = $1, + rejected_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE email = $2 + RETURNING * + `; + + const result = await this.query(query, [rejectedBy, userEmail]); + + // Log audit + if (result.rows[0]) { + await this.createAuditLog('user_rejected', userEmail, rejectedBy, { reason }); + } + + return result.rows[0] || null; + } + + async deactivateUser(userEmail: string, deactivatedBy: string): Promise { + const query = ` + UPDATE users + SET status = 'deactivated', + deactivated_by = $1, + deactivated_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE email = $2 + RETURNING * + `; + + const result = await this.query(query, [deactivatedBy, userEmail]); + + // Log audit + if (result.rows[0]) { + await this.createAuditLog('user_deactivated', userEmail, deactivatedBy, {}); + } + + return result.rows[0] || null; + } + + async reactivateUser(userEmail: string, reactivatedBy: string): Promise { + const query = ` + UPDATE users + SET status = 'active', + deactivated_by = NULL, + deactivated_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE email = $1 + RETURNING * + `; + + const result = await this.query(query, [userEmail]); + + // Log audit + if (result.rows[0]) { + await this.createAuditLog('user_reactivated', userEmail, reactivatedBy, {}); + } + + return result.rows[0] || null; + } + + async createAuditLog(action: string, userEmail: string, performedBy: string, details: any): Promise { + const query = ` + INSERT INTO user_audit_log (action, user_email, performed_by, action_details) + VALUES ($1, $2, $3, $4) + `; + + await this.query(query, [action, userEmail, performedBy, JSON.stringify(details)]); + } + + async getUserAuditLog(userEmail: string): Promise { + const query = ` + SELECT * FROM user_audit_log + WHERE user_email = $1 + ORDER BY created_at DESC + `; + + const result = await this.query(query, [userEmail]); + return result.rows; + } + + async getUsersWithFilters(filters: { + status?: string; + role?: string; + search?: string; + }): Promise { + let query = 'SELECT * FROM users WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + if (filters.status) { + query += ` AND status = $${paramIndex}`; + params.push(filters.status); + paramIndex++; + } + + if (filters.role) { + query += ` AND role = $${paramIndex}`; + params.push(filters.role); + paramIndex++; + } + + if (filters.search) { + query += ` AND (LOWER(name) LIKE LOWER($${paramIndex}) OR LOWER(email) LIKE LOWER($${paramIndex}) OR LOWER(organization) LIKE LOWER($${paramIndex}))`; + params.push(`%${filters.search}%`); + paramIndex++; + } + + query += ' ORDER BY created_at DESC'; + + const result = await this.query(query, params); + return result.rows; + } + + // Fix for first user admin issue + async getActiveUserCount(): Promise { + const query = "SELECT COUNT(*) as count FROM users WHERE status = 'active'"; const result = await this.query(query); return parseInt(result.rows[0].count); } - // User approval methods - async updateUserApprovalStatus(email: string, status: 'pending' | 'approved' | 'denied'): Promise { - const query = ` - UPDATE users - SET approval_status = $1, updated_at = CURRENT_TIMESTAMP - WHERE email = $2 - RETURNING * - `; - - const result = await this.query(query, [status, email]); - if (result.rows[0]) { - console.log(`๐Ÿ‘ค Updated user approval: ${result.rows[0].name} (${email}) -> ${status}`); - } - return result.rows[0] || null; + async isFirstUser(): Promise { + return this.backupService.isFirstUser(); } - async getPendingUsers(): Promise { - const query = 'SELECT * FROM users WHERE approval_status = $1 ORDER BY created_at ASC'; - const result = await this.query(query, ['pending']); - return result.rows; + // VIP methods from backup service + async createVip(vip: any): Promise { + return this.backupService.createVip(vip); } - async getApprovedUserCount(): Promise { - const query = 'SELECT COUNT(*) as count FROM users WHERE approval_status = $1'; - const result = await this.query(query, ['approved']); - return parseInt(result.rows[0].count); + async getVipById(id: string): Promise { + return this.backupService.getVipById(id); } - // Initialize all database tables and schema - async initializeDatabase(): Promise { - try { - await this.initializeTables(); - await this.initializeVipTables(); - - // Approve all existing users (migration for approval system) - await this.query(` - UPDATE users - SET approval_status = 'approved' - WHERE approval_status IS NULL OR approval_status = 'pending' - `); - console.log('โœ… Approved all existing users'); - - console.log('โœ… Database schema initialization completed'); - } catch (error) { - console.error('โŒ Failed to initialize database schema:', error); - throw error; - } + async getAllVips(): Promise { + return this.backupService.getAllVips(); } - // VIP table initialization using the correct schema - async initializeVipTables(): Promise { - 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 - ) - `); - - // Create flights table (for VIPs with flight transport) - await this.query(` - CREATE TABLE IF NOT EXISTS flights ( - id SERIAL PRIMARY KEY, - vip_id VARCHAR(255) REFERENCES vips(id) ON DELETE CASCADE, - flight_number VARCHAR(50) NOT NULL, - flight_date DATE NOT NULL, - segment INTEGER NOT NULL, - departure_airport VARCHAR(10), - arrival_airport VARCHAR(10), - scheduled_departure TIMESTAMP, - scheduled_arrival TIMESTAMP, - actual_departure TIMESTAMP, - actual_arrival TIMESTAMP, - status VARCHAR(50), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - // 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 - ) - `); - - // 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, - vip_id VARCHAR(255) REFERENCES vips(id) ON DELETE CASCADE, - title VARCHAR(255) NOT NULL, - location VARCHAR(255) NOT NULL, - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - description TEXT, - assigned_driver_id VARCHAR(255) REFERENCES drivers(id) ON DELETE SET NULL, - status VARCHAR(50) DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'in-progress', 'completed', 'cancelled')), - event_type VARCHAR(50) NOT NULL CHECK (event_type IN ('transport', 'meeting', 'event', 'meal', 'accommodation')), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Create system_setup table for tracking initial setup - await this.query(` - 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(` - 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 OR REPLACE FUNCTION update_updated_at_column() - RETURNS TRIGGER AS $$ - BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; - END; - $$ language 'plpgsql' - `); - - // 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; - } + async updateVip(id: string, vip: any): Promise { + return this.backupService.updateVip(id, vip); } - // Redis-based driver location tracking - async getDriverLocation(driverId: string): Promise<{ lat: number; lng: number } | null> { - try { - if (!this.redis.isOpen) { - await this.redis.connect(); - } - - const location = await this.redis.hGetAll(`driver:${driverId}:location`); - - if (location && location.lat && location.lng) { - return { - lat: parseFloat(location.lat), - lng: parseFloat(location.lng) - }; - } - - return null; - } catch (error) { - console.error('โŒ Error getting driver location from Redis:', error); - return null; - } + async deleteVip(id: string): Promise { + return this.backupService.deleteVip(id); } - async updateDriverLocation(driverId: string, location: { lat: number; lng: number }): Promise { - try { - if (!this.redis.isOpen) { - await this.redis.connect(); - } - - const key = `driver:${driverId}:location`; - await this.redis.hSet(key, { - lat: location.lat.toString(), - lng: location.lng.toString(), - updated_at: new Date().toISOString() - }); - - // Set expiration to 24 hours - await this.redis.expire(key, 24 * 60 * 60); - } catch (error) { - console.error('โŒ Error updating driver location in Redis:', error); - } + async getVipsByDepartment(department: string): Promise { + return this.backupService.getVipsByDepartment(department); } - async getAllDriverLocations(): Promise<{ [driverId: string]: { lat: number; lng: number } }> { - try { - if (!this.redis.isOpen) { - await this.redis.connect(); - } - - const keys = await this.redis.keys('driver:*:location'); - const locations: { [driverId: string]: { lat: number; lng: number } } = {}; - - for (const key of keys) { - const driverId = key.split(':')[1]; - const location = await this.redis.hGetAll(key); - - if (location && location.lat && location.lng) { - locations[driverId] = { - lat: parseFloat(location.lat), - lng: parseFloat(location.lng) - }; - } - } - - return locations; - } catch (error) { - console.error('โŒ Error getting all driver locations from Redis:', error); - return {}; - } + // Driver methods from backup service + async createDriver(driver: any): Promise { + return this.backupService.createDriver(driver); } - async removeDriverLocation(driverId: string): Promise { - try { - if (!this.redis.isOpen) { - await this.redis.connect(); - } - - await this.redis.del(`driver:${driverId}:location`); - } catch (error) { - console.error('โŒ Error removing driver location from Redis:', error); - } + async getDriverById(id: string): Promise { + return this.backupService.getDriverById(id); + } + + async getAllDrivers(): Promise { + return this.backupService.getAllDrivers(); + } + + async updateDriver(id: string, driver: any): Promise { + return this.backupService.updateDriver(id, driver); + } + + async deleteDriver(id: string): Promise { + return this.backupService.deleteDriver(id); + } + + async getDriversByDepartment(department: string): Promise { + return this.backupService.getDriversByDepartment(department); + } + + async updateDriverLocation(id: string, location: any): Promise { + return this.backupService.updateDriverLocation(id, location); + } + + // Schedule methods from backup service + async createScheduleEvent(vipId: string, event: any): Promise { + return this.backupService.createScheduleEvent(vipId, event); + } + + async getScheduleByVipId(vipId: string): Promise { + return this.backupService.getScheduleByVipId(vipId); + } + + async updateScheduleEvent(vipId: string, eventId: string, event: any): Promise { + return this.backupService.updateScheduleEvent(vipId, eventId, event); + } + + async deleteScheduleEvent(vipId: string, eventId: string): Promise { + return this.backupService.deleteScheduleEvent(vipId, eventId); + } + + async getAllScheduleEvents(): Promise { + return this.backupService.getAllScheduleEvents(); + } + + async getScheduleEventsByDateRange(startDate: Date, endDate: Date): Promise { + return this.backupService.getScheduleEventsByDateRange(startDate, endDate); } } -export default new DatabaseService(); +// Export singleton instance +const databaseService = new EnhancedDatabaseService(); +export default databaseService; \ No newline at end of file diff --git a/backend/src/services/jwtKeyManager.ts b/backend/src/services/jwtKeyManager.ts index 1209a90..019f3f2 100644 --- a/backend/src/services/jwtKeyManager.ts +++ b/backend/src/services/jwtKeyManager.ts @@ -8,10 +8,13 @@ export interface User { name: string; profile_picture_url?: string; role: 'driver' | 'coordinator' | 'administrator'; + status?: 'pending' | 'active' | 'deactivated'; created_at?: string; last_login?: string; is_active?: boolean; updated_at?: string; + approval_status?: string; + onboardingData?: any; } class JWTKeyManager { @@ -78,6 +81,9 @@ class JWTKeyManager { name: user.name, profile_picture_url: user.profile_picture_url, role: user.role, + status: user.status, + approval_status: user.approval_status, + onboardingData: user.onboardingData, iat: Math.floor(Date.now() / 1000) // Issued at time }; @@ -102,7 +108,10 @@ class JWTKeyManager { email: decoded.email, name: decoded.name, profile_picture_url: decoded.profile_picture_url, - role: decoded.role + role: decoded.role, + status: decoded.status, + approval_status: decoded.approval_status, + onboardingData: decoded.onboardingData }; } catch (error) { // Try previous secret during grace period @@ -121,7 +130,10 @@ class JWTKeyManager { email: decoded.email, name: decoded.name, profile_picture_url: decoded.profile_picture_url, - role: decoded.role + role: decoded.role, + status: decoded.status, + approval_status: decoded.approval_status, + onboardingData: decoded.onboardingData }; } catch (gracePeriodError) { console.log('โŒ Token verification failed with both current and previous keys'); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 05f269c..c511b28 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -17,5 +17,5 @@ "moduleResolution": "node" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.original.ts", "src/**/backup-services/**", "src/routes/simpleAuth.ts", "src/config/simpleAuth.ts"] } diff --git a/deploy.sh b/deploy.sh index 32b7e08..5779f92 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,130 +1,139 @@ #!/bin/bash -# VIP Coordinator - Quick Deployment Script -# This script helps you deploy VIP Coordinator with Docker +# VIP Coordinator Quick Deploy Script +# This script helps you deploy the VIP Coordinator application using Docker set -e -echo "๐Ÿš€ VIP Coordinator - Quick Deployment Script" -echo "=============================================" +echo "๐Ÿš€ VIP Coordinator Deployment Script" +echo "====================================" # Check if Docker is installed if ! command -v docker &> /dev/null; then echo "โŒ Docker is not installed. Please install Docker first." - echo " Visit: https://docs.docker.com/get-docker/" exit 1 fi # Check if Docker Compose is installed if ! command -v docker-compose &> /dev/null; then echo "โŒ Docker Compose is not installed. Please install Docker Compose first." - echo " Visit: https://docs.docker.com/compose/install/" exit 1 fi -echo "โœ… Docker and Docker Compose are installed" - # Check if .env file exists if [ ! -f ".env" ]; then + echo "โš ๏ธ No .env file found. Creating one from .env.example..." if [ -f ".env.example" ]; then - echo "๐Ÿ“ Creating .env file from template..." cp .env.example .env - echo "โš ๏ธ IMPORTANT: Please edit .env file with your configuration before continuing!" - echo " Required changes:" - echo " - DB_PASSWORD: Set a secure database password" - echo " - ADMIN_PASSWORD: Set a secure admin password" - echo " - GOOGLE_CLIENT_ID: Your Google OAuth Client ID" - echo " - GOOGLE_CLIENT_SECRET: Your Google OAuth Client Secret" - echo " - Update domain settings for production deployment" + echo "โœ… Created .env file from .env.example" echo "" - read -p "Press Enter after you've updated the .env file..." + echo "๐Ÿ”ง IMPORTANT: Please edit the .env file and update the following:" + echo " - POSTGRES_PASSWORD (set a secure password)" + echo " - GOOGLE_CLIENT_ID (from Google Cloud Console)" + echo " - GOOGLE_CLIENT_SECRET (from Google Cloud Console)" + echo " - VITE_API_URL (your backend URL)" + echo " - VITE_FRONTEND_URL (your frontend URL)" + echo "" + echo "๐Ÿ“– For Google OAuth setup instructions, see:" + echo " https://console.cloud.google.com/" + echo "" + read -p "Press Enter after updating the .env file to continue..." else - echo "โŒ .env.example file not found. Please ensure you have the deployment files." + echo "โŒ .env.example file not found. Please create a .env file manually." exit 1 fi fi # Validate required environment variables -echo "๐Ÿ” Validating configuration..." +echo "๐Ÿ” Validating environment configuration..." +# Source the .env file +set -a source .env +set +a -if [ -z "$DB_PASSWORD" ] || [ "$DB_PASSWORD" = "VipCoord2025SecureDB" ]; then - echo "โš ๏ธ Warning: Please change DB_PASSWORD from the default value" -fi +# Check required variables +REQUIRED_VARS=("POSTGRES_PASSWORD" "GOOGLE_CLIENT_ID" "GOOGLE_CLIENT_SECRET") +MISSING_VARS=() -if [ -z "$ADMIN_PASSWORD" ] || [ "$ADMIN_PASSWORD" = "ChangeThisSecurePassword" ]; then - echo "โš ๏ธ Warning: Please change ADMIN_PASSWORD from the default value" -fi +for var in "${REQUIRED_VARS[@]}"; do + if [ -z "${!var}" ] || [ "${!var}" = "your_secure_password_here" ] || [ "${!var}" = "your_google_client_id_here" ] || [ "${!var}" = "your_google_client_secret_here" ]; then + MISSING_VARS+=("$var") + fi +done -if [ -z "$GOOGLE_CLIENT_ID" ] || [ "$GOOGLE_CLIENT_ID" = "your-google-client-id.apps.googleusercontent.com" ]; then - echo "โŒ Error: GOOGLE_CLIENT_ID must be configured" - echo " Please set up Google OAuth and update your .env file" +if [ ${#MISSING_VARS[@]} -ne 0 ]; then + echo "โŒ The following required environment variables are missing or have default values:" + for var in "${MISSING_VARS[@]}"; do + echo " - $var" + done + echo "" + echo "Please update your .env file with the correct values." exit 1 fi -if [ -z "$GOOGLE_CLIENT_SECRET" ] || [ "$GOOGLE_CLIENT_SECRET" = "your-google-client-secret" ]; then - echo "โŒ Error: GOOGLE_CLIENT_SECRET must be configured" - echo " Please set up Google OAuth and update your .env file" - exit 1 -fi +echo "โœ… Environment configuration looks good!" -echo "โœ… Configuration validated" +# Pull the latest images +echo "" +echo "๐Ÿ“ฅ Pulling latest Docker images..." +docker pull t72chevy/vip-coordinator:backend-latest +docker pull t72chevy/vip-coordinator:frontend-latest -# Pull latest images -echo "๐Ÿ“ฅ Pulling latest images from Docker Hub..." -docker-compose pull +# Stop existing containers if running +echo "" +echo "๐Ÿ›‘ Stopping existing containers (if any)..." +docker-compose down --remove-orphans || true # Start the application -echo "๐Ÿš€ Starting VIP Coordinator..." +echo "" +echo "๐Ÿš€ Starting VIP Coordinator application..." docker-compose up -d -# Wait for services to be ready +# Wait for services to be healthy +echo "" echo "โณ Waiting for services to start..." sleep 10 # Check service status -echo "๐Ÿ” Checking service status..." +echo "" +echo "๐Ÿ“Š Service Status:" docker-compose ps -# Check if backend is healthy -echo "๐Ÿฅ Checking backend health..." -for i in {1..30}; do - if curl -s http://localhost:3000/health > /dev/null 2>&1; then - echo "โœ… Backend is healthy" - break - fi - if [ $i -eq 30 ]; then - echo "โŒ Backend health check failed" - echo " Check logs with: docker-compose logs backend" - exit 1 - fi - sleep 2 -done +# Check if services are healthy +echo "" +echo "๐Ÿฅ Health Checks:" -# Check if frontend is accessible -echo "๐ŸŒ Checking frontend..." -if curl -s http://localhost/ > /dev/null 2>&1; then +# Check backend health +if curl -f -s http://localhost:3000/health > /dev/null 2>&1; then + echo "โœ… Backend is healthy" +else + echo "โš ๏ธ Backend health check failed (may still be starting up)" +fi + +# Check frontend +if curl -f -s http://localhost > /dev/null 2>&1; then echo "โœ… Frontend is accessible" else - echo "โš ๏ธ Frontend check failed, but this might be normal during startup" + echo "โš ๏ธ Frontend health check failed (may still be starting up)" fi echo "" -echo "๐ŸŽ‰ VIP Coordinator deployment completed!" -echo "=============================================" -echo "๐Ÿ“ Access your application:" +echo "๐ŸŽ‰ Deployment complete!" +echo "" +echo "๐Ÿ“ฑ Access your application:" echo " Frontend: http://localhost" echo " Backend API: http://localhost:3000" +echo " Health Check: http://localhost:3000/health" echo "" -echo "๐Ÿ“‹ Next steps:" -echo " 1. Open http://localhost in your browser" -echo " 2. Click 'Continue with Google' to set up your admin account" -echo " 3. The first user to log in becomes the administrator" +echo "๐Ÿ“‹ Useful commands:" +echo " View logs: docker-compose logs -f" +echo " Stop app: docker-compose down" +echo " Restart: docker-compose restart" +echo " Update: docker-compose pull && docker-compose up -d" echo "" -echo "๐Ÿ”ง Management commands:" -echo " View logs: docker-compose logs" -echo " Stop app: docker-compose down" -echo " Update app: docker-compose pull && docker-compose up -d" -echo "" -echo "๐Ÿ“– For production deployment, see DEPLOYMENT.md" \ No newline at end of file +echo "๐Ÿ†˜ If you encounter issues:" +echo " 1. Check logs: docker-compose logs" +echo " 2. Verify .env configuration" +echo " 3. Ensure Google OAuth is properly configured" +echo " 4. Check that ports 80 and 3000 are available" \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0b3e5f1..2d81475 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,8 +5,9 @@ services: db: image: postgres:15 environment: - POSTGRES_DB: vip_coordinator - POSTGRES_PASSWORD: changeme + POSTGRES_DB: ${POSTGRES_DB:-vip_coordinator} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres-data:/var/lib/postgresql/data ports: @@ -22,8 +23,14 @@ services: context: ./backend target: development environment: - DATABASE_URL: postgresql://postgres:changeme@db:5432/vip_coordinator - REDIS_URL: redis://redis:6379 + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI} + FRONTEND_URL: ${FRONTEND_URL} + JWT_SECRET: ${JWT_SECRET} + NODE_ENV: ${NODE_ENV:-development} ports: - 3000:3000 depends_on: @@ -38,7 +45,8 @@ services: context: ./frontend target: development environment: - VITE_API_URL: http://localhost:3000/api + VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api} + VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} ports: - 5173:5173 depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 014903c..288b553 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,57 +1,88 @@ version: '3.8' services: - - db: - image: postgres:15 + postgres: + image: postgres:15-alpine environment: - POSTGRES_DB: vip_coordinator - POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-vip_coordinator} + POSTGRES_USER: ${POSTGRES_USER:-vip_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - - postgres-data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 30s - timeout: 10s - retries: 3 + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vip_user} -d ${POSTGRES_DB:-vip_coordinator}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - vip-network redis: - image: redis:7 + image: redis:7-alpine + ports: + - "6379:6379" restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] - interval: 30s - timeout: 10s - retries: 3 + interval: 10s + timeout: 5s + retries: 5 + networks: + - vip-network backend: image: t72chevy/vip-coordinator:backend-latest environment: - DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@db:5432/vip_coordinator - REDIS_URL: redis://redis:6379 - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} - GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI} - FRONTEND_URL: ${FRONTEND_URL} - ADMIN_PASSWORD: ${ADMIN_PASSWORD} - PORT: 3000 + - DATABASE_URL=${DATABASE_URL} + - NODE_ENV=${NODE_ENV:-production} + - PORT=${PORT:-3000} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - JWT_SECRET=${JWT_SECRET:-auto-generated} ports: - "3000:3000" + restart: unless-stopped depends_on: - db: + postgres: condition: service_healthy redis: condition: service_healthy - restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - vip-network frontend: image: t72chevy/vip-coordinator:frontend-latest + environment: + - VITE_API_URL=${VITE_API_URL:-http://localhost:3001} + - VITE_FRONTEND_URL=${VITE_FRONTEND_URL:-http://localhost} ports: - "80:80" - depends_on: - - backend restart: unless-stopped + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - vip-network volumes: - postgres-data: \ No newline at end of file + postgres_data: + driver: local + +networks: + vip-network: + driver: bridge \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index e68ec3b..e45eaee 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,8 @@ VIP Coordinator Dashboard + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cec5403..7841a3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,19 +15,19 @@ "react-router-dom": "^6.15.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.8", "@types/leaflet": "^1.9.4", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitejs/plugin-react": "^4.3.3", - "autoprefixer": "^10.4.14", + "autoprefixer": "^10.4.21", "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", - "postcss": "^8.5.4", - "tailwindcss": "^4.1.8", + "lightningcss": "^1.30.1", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", "typescript": "^5.6.0", "vite": "^5.4.10" }, @@ -38,6 +38,8 @@ }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "dev": true, "license": "MIT", "engines": { @@ -961,15 +963,22 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, "license": "ISC", "dependencies": { - "minipass": "^7.0.4" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=12" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1053,6 +1062,17 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@react-leaflet/core": { "version": "2.1.0", "license": "Hippocratic-2.1", @@ -1354,74 +1374,6 @@ "win32" ] }, - "node_modules/@tailwindcss/node": { - "version": "4.1.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.30.1", - "magic-string": "^0.30.17", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.8" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.8", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.8", - "@tailwindcss/oxide-darwin-arm64": "4.1.8", - "@tailwindcss/oxide-darwin-x64": "4.1.8", - "@tailwindcss/oxide-freebsd-x64": "4.1.8", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.8", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.8", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.8", - "@tailwindcss/oxide-linux-x64-musl": "4.1.8", - "@tailwindcss/oxide-wasm32-wasi": "4.1.8", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.8", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.8" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.8", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.8", - "@tailwindcss/oxide": "4.1.8", - "postcss": "^8.4.41", - "tailwindcss": "4.1.8" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -1801,6 +1753,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "dev": true, @@ -1815,6 +1780,34 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1824,6 +1817,8 @@ }, "node_modules/autoprefixer": { "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", "dev": true, "funding": [ { @@ -1863,6 +1858,19 @@ "dev": true, "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -1927,6 +1935,16 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001720", "dev": true, @@ -1961,12 +1979,42 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chownr": { - "version": "3.0.0", + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, "engines": { - "node": ">=18" + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/color-convert": { @@ -1985,6 +2033,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -2008,6 +2066,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "dev": true, @@ -2042,22 +2113,38 @@ "node": ">=8" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.161", "dev": true, "license": "ISC" }, - "node_modules/enhanced-resolve": { - "version": "5.18.1", + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } + "license": "MIT" }, "node_modules/esbuild": { "version": "0.21.5", @@ -2464,6 +2551,23 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "dev": true, @@ -2491,6 +2595,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -2499,6 +2613,27 @@ "node": ">=6.9.0" } }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "dev": true, @@ -2518,11 +2653,6 @@ "node": ">=4" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "dev": true, - "license": "ISC" - }, "node_modules/graphemer": { "version": "1.4.0", "dev": true, @@ -2536,6 +2666,19 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2571,6 +2714,35 @@ "node": ">=0.8.19" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "dev": true, @@ -2579,6 +2751,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -2605,10 +2787,28 @@ "dev": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.4.2", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -2699,6 +2899,8 @@ }, "node_modules/lightningcss": { "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2724,6 +2926,132 @@ "lightningcss-win32-x64-msvc": "1.30.1" } }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.30.1", "cpu": [ @@ -2743,6 +3071,89 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -2780,14 +3191,6 @@ "yallist": "^3.0.2" } }, - "node_modules/magic-string": { - "version": "0.30.17", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2830,42 +3233,31 @@ }, "node_modules/minipass": { "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ms": { "version": "2.1.3", "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "dev": true, @@ -2893,6 +3285,16 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-range": { "version": "0.1.2", "dev": true, @@ -2901,6 +3303,26 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -2945,6 +3367,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2974,6 +3403,37 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/picocolors": { "version": "1.1.1", "dev": true, @@ -2992,8 +3452,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { - "version": "8.5.4", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -3019,6 +3501,120 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "dev": true, @@ -3132,6 +3728,50 @@ "react-dom": ">=16.8" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3263,6 +3903,19 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "dev": true, @@ -3271,6 +3924,110 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3284,6 +4041,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -3295,41 +4075,88 @@ "node": ">=8" } }, - "node_modules/tailwindcss": { - "version": "4.1.8", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.2.2", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar": { - "version": "7.4.3", + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, "engines": { - "node": ">=18" + "node": ">=0.8" } }, "node_modules/to-regex-range": { @@ -3358,6 +4185,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -3420,6 +4254,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.19", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", @@ -3502,11 +4343,119 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 908bc2c..67a9f58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,10 @@ "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" + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" }, "dependencies": { "leaflet": "^1.9.4", @@ -21,20 +24,27 @@ "react-router-dom": "^6.15.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", "@types/leaflet": "^1.9.4", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitejs/plugin-react": "^4.3.3", - "autoprefixer": "^10.4.14", + "@vitest/coverage-v8": "^1.3.1", + "@vitest/ui": "^1.3.1", + "autoprefixer": "^10.4.21", "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", + "jsdom": "^24.0.0", + "lightningcss": "^1.30.1", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", "typescript": "^5.6.0", - "vite": "^5.4.10" + "vite": "^5.4.10", + "vitest": "^1.3.1" } } diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs index 8416a01..52012d2 100644 --- a/frontend/postcss.config.mjs +++ b/frontend/postcss.config.mjs @@ -1,6 +1,6 @@ export default { plugins: { - '@tailwindcss/postcss': {}, + tailwindcss: {}, autoprefixer: {}, } } \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 0b0ce05..2d7a4df 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,187 +1 @@ -/* Modern App-specific styles using Tailwind utilities */ - -/* Enhanced button styles */ -@layer components { - .btn-modern { - @apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5; - } - - .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 */ -@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-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-cancelled { - @apply bg-red-100 text-red-800 border border-red-200; - } -} - -/* Card enhancements */ -@layer components { - .card-modern { - @apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm; - } - - .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 */ -@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; - } -} - -/* Form enhancements */ -@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 */ -@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 { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes slideUp { - from { - transform: translateY(10px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -@keyframes scaleIn { - from { - transform: scale(0.95); - opacity: 0; - } - to { - transform: scale(1); - opacity: 1; - } -} - -/* Responsive utilities */ -@media (max-width: 768px) { - .mobile-stack { - @apply flex-col space-y-4 space-x-0; - } - - .mobile-full { - @apply w-full; - } - - .mobile-text-center { - @apply text-center; - } -} - -/* Glass morphism effect */ -@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 */ -@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; - } -} +/* Modern App-specific styles - Component classes moved to inline Tailwind */ \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e5cb037..bf3fdad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,57 +1,68 @@ import { useState, useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; -import { apiCall } from './config/api'; +import { apiCall } from './utils/api'; import VipList from './pages/VipList'; import VipDetails from './pages/VipDetails'; import DriverList from './pages/DriverList'; import DriverDashboard from './pages/DriverDashboard'; import Dashboard from './pages/Dashboard'; import AdminDashboard from './pages/AdminDashboard'; +import PendingApproval from './pages/PendingApproval'; import UserManagement from './components/UserManagement'; import Login from './components/Login'; +import OAuthCallback from './components/OAuthCallback'; import './App.css'; +import { User } from './types'; function App() { - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { // Check if user is already authenticated const token = localStorage.getItem('authToken'); - if (token) { + const savedUser = localStorage.getItem('user'); + + if (token && savedUser) { + // Use saved user data for faster initial load + setUser(JSON.parse(savedUser)); + setLoading(false); + + // Then verify with server apiCall('/auth/me', { headers: { 'Authorization': `Bearer ${token}` } }) - .then(res => { - if (res.ok) { - return res.json(); + .then(({ data }) => { + if (data) { + setUser(data as User); + localStorage.setItem('user', JSON.stringify(data)); } else { // Token is invalid, remove it localStorage.removeItem('authToken'); - throw new Error('Invalid token'); + localStorage.removeItem('user'); + setUser(null); } }) - .then(userData => { - setUser(userData); - setLoading(false); - }) .catch(error => { console.error('Auth check failed:', error); - setLoading(false); + localStorage.removeItem('authToken'); + localStorage.removeItem('user'); + setUser(null); }); } else { setLoading(false); } }, []); - const handleLogin = (userData: any) => { + const handleLogin = (userData: User) => { setUser(userData); }; const handleLogout = () => { localStorage.removeItem('authToken'); + localStorage.removeItem('user'); setUser(null); // Optionally call logout endpoint apiCall('/auth/logout', { method: 'POST' }) @@ -71,13 +82,52 @@ function App() { // Handle OAuth callback route even when not logged in if (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback') { - return ; + return ( + + + } /> + + + ); } if (!user) { return ; } + // Check if user is pending approval + if (user.role !== 'administrator' && (!user.status || user.status === 'pending')) { + return ( + + + } /> + + + ); + } + + // Check if user is deactivated + if (user.status === 'deactivated') { + return ( +
+
+
+ + + +
+

Account Deactivated

+

+ Your account has been deactivated. Please contact an administrator for assistance. +

+ +
+
+ ); + } + return (
diff --git a/frontend/src/components/DriverSelector.tsx b/frontend/src/components/DriverSelector.tsx index b6afbba..dfd34c8 100644 --- a/frontend/src/components/DriverSelector.tsx +++ b/frontend/src/components/DriverSelector.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; interface DriverAvailability { driverId: string; @@ -60,7 +60,7 @@ const DriverSelector: React.FC = ({ setLoading(true); try { const token = localStorage.getItem('authToken'); - const response = await apiCall('/api/drivers/availability', { + const { data } = await apiCall('/api/drivers/availability', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -69,8 +69,7 @@ const DriverSelector: React.FC = ({ body: JSON.stringify(eventTime), }); - if (response.ok) { - const data = await response.json(); + if (data) { setAvailability(data); } } catch (error) { diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 62832b1..4922abc 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,115 +1,58 @@ import React, { useEffect, useState } from 'react'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; +import GoogleLogin from './GoogleLogin'; import './Login.css'; +import { User } from '../types'; interface LoginProps { - onLogin: (user: any) => void; + onLogin: (user: User) => void; +} + +interface SetupStatus { + ready: boolean; + hasUsers: boolean; + missingEnvVars?: string[]; } const Login: React.FC = ({ onLogin }) => { - const [setupStatus, setSetupStatus] = useState(null); + const [setupStatus, setSetupStatus] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { // Check system setup status apiCall('/auth/setup') - .then(res => res.json()) - .then(data => { + .then(({ data }) => { setSetupStatus(data); setLoading(false); }) .catch(error => { console.error('Error checking setup status:', error); + setSetupStatus({ ready: true, hasUsers: false }); // Assume ready if can't check 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'); + const handleGoogleSuccess = (user: any, token: string) => { + // Store the token and user data + localStorage.setItem('authToken', token); + localStorage.setItem('user', JSON.stringify(user)); + + // Call onLogin with the user data + onLogin(user); + }; - 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 => { - if (!res.ok) { - throw new Error(`Failed to get user info: ${res.status} ${res.statusText}`); - } - return 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); - alert('Login failed. Please try again.'); - 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.'); - } + const handleGoogleError = (errorMessage: string) => { + setError(errorMessage); + setTimeout(() => setError(null), 5000); // Clear error after 5 seconds }; if (loading) { return (
-
-
Loading...
+
+

VIP Coordinator

+

Loading...

); @@ -117,68 +60,33 @@ const Login: React.FC = ({ onLogin }) => { return (
-
-
-

VIP Coordinator

-

Secure access required

-
- - {!setupStatus?.firstAdminCreated && ( -
-

๐Ÿš€ First Time Setup

-

The first person to log in will become the system administrator.

+
+

VIP Coordinator

+

Transportation Management System

+ + {error && ( +
+ {error}
)}
- - -
-

- {setupStatus?.firstAdminCreated - ? "Sign in with your Google account to access the VIP Coordinator." - : "Sign in with Google to set up your administrator account." - } -

-
- - {setupStatus && !setupStatus.setupCompleted && ( -
- โš ๏ธ Setup Required: -

- Google OAuth credentials need to be configured. If the login doesn't work, - please follow the setup guide in GOOGLE_OAUTH_SETUP.md to configure - your Google Cloud Console credentials in the admin dashboard. + + +

+ {setupStatus && !setupStatus.hasUsers && ( +

+ First user to log in will become an administrator

-
- )} -
- -
-

Secure authentication powered by Google OAuth

+ )} +
); }; -export default Login; +export default Login; \ No newline at end of file diff --git a/frontend/src/components/ScheduleManager.tsx b/frontend/src/components/ScheduleManager.tsx index ddb24bc..8c512fe 100644 --- a/frontend/src/components/ScheduleManager.tsx +++ b/frontend/src/components/ScheduleManager.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; import DriverSelector from './DriverSelector'; interface ScheduleEvent { @@ -33,15 +33,14 @@ const ScheduleManager: React.FC = ({ vipId, vipName }) => const fetchSchedule = async () => { try { const token = localStorage.getItem('authToken'); - const response = await apiCall(`/api/vips/${vipId}/schedule`, { + const { data } = await apiCall(`/api/vips/${vipId}/schedule`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (response.ok) { - const data = await response.json(); + if (data) { setSchedule(data); } } catch (error) { @@ -52,15 +51,14 @@ const ScheduleManager: React.FC = ({ vipId, vipName }) => const fetchDrivers = async () => { try { const token = localStorage.getItem('authToken'); - const response = await apiCall('/api/drivers', { + const { data } = await apiCall('/api/drivers', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (response.ok) { - const data = await response.json(); + if (data) { setDrivers(data); } } catch (error) { @@ -305,7 +303,7 @@ const ScheduleManager: React.FC = ({ vipId, vipName }) => async function handleAddEvent(eventData: any) { try { const token = localStorage.getItem('authToken'); - const response = await apiCall(`/api/vips/${vipId}/schedule`, { + const { data } = await apiCall(`/api/vips/${vipId}/schedule`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -314,12 +312,11 @@ const ScheduleManager: React.FC = ({ vipId, vipName }) => body: JSON.stringify(eventData), }); - if (response.ok) { + if (data) { await fetchSchedule(); setShowAddForm(false); } else { - const errorData = await response.json(); - throw errorData; + throw new Error('Failed to add event'); } } catch (error) { console.error('Error adding event:', error); @@ -330,7 +327,7 @@ const ScheduleManager: React.FC = ({ vipId, vipName }) => async function handleEditEvent(eventData: any) { try { const token = localStorage.getItem('authToken'); - const response = await apiCall(`/api/vips/${vipId}/schedule/${eventData.id}`, { + const { data } = await apiCall(`/api/vips/${vipId}/schedule/${eventData.id}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, @@ -339,12 +336,11 @@ const ScheduleManager: React.FC = ({ vipId, vipName }) => body: JSON.stringify(eventData), }); - if (response.ok) { + if (data) { await fetchSchedule(); setEditingEvent(null); } else { - const errorData = await response.json(); - throw errorData; + throw new Error('Failed to update event'); } } catch (error) { console.error('Error updating event:', error); diff --git a/frontend/src/components/UserManagement.tsx b/frontend/src/components/UserManagement.tsx index 0ce7e58..92b7251 100644 --- a/frontend/src/components/UserManagement.tsx +++ b/frontend/src/components/UserManagement.tsx @@ -1,488 +1,458 @@ -import { useState, useEffect } from 'react'; -import { API_BASE_URL } from '../config/api'; - -interface User { - id: string; - email: string; - name: string; - picture: string; - role: string; - created_at: string; - last_sign_in_at?: string; - provider: string; -} +import React, { useState, useEffect } from 'react'; +import { apiCall } from '../utils/api'; +import { User } from '../types'; +import { useToast } from '../contexts/ToastContext'; +import { LoadingSpinner } from './LoadingSpinner'; interface UserManagementProps { - currentUser: any; + currentUserId: string; } -const UserManagement: React.FC = ({ currentUser }) => { +const UserManagement: React.FC = ({ currentUserId }) => { + const { showToast } = useToast(); const [users, setUsers] = useState([]); - const [pendingUsers, setPendingUsers] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'all' | 'pending'>('all'); - const [updatingUser, setUpdatingUser] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [filterRole, setFilterRole] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [selectedUser, setSelectedUser] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); - // Check if current user is admin - if (currentUser?.role !== 'administrator') { - return ( -
-

Access Denied

-

You need administrator privileges to access user management.

-
- ); - } + useEffect(() => { + fetchUsers(); + }, []); const fetchUsers = async () => { try { const token = localStorage.getItem('authToken'); - const response = await fetch(`${API_BASE_URL}/auth/users`, { + const { data } = await apiCall('/auth/users', { headers: { 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } + }, }); - if (!response.ok) { - throw new Error('Failed to fetch users'); + if (data) { + setUsers(data); + } else { + showToast('Failed to load users', 'error'); } - - const userData = await response.json(); - setUsers(userData); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch users'); + } catch (error) { + showToast('Error loading users', 'error'); } finally { setLoading(false); } }; - const fetchPendingUsers = async () => { + const handleApprove = async (userEmail: string, role: string) => { try { const token = localStorage.getItem('authToken'); - const response = await fetch(`${API_BASE_URL}/auth/users/pending/list`, { + const { data } = await apiCall(`/auth/users/${userEmail}/approve`, { + method: 'POST', headers: { 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - throw new Error('Failed to fetch pending users'); - } - - const pendingData = await response.json(); - setPendingUsers(pendingData); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch pending users'); - } - }; - - const updateUserRole = async (userEmail: string, newRole: string) => { - setUpdatingUser(userEmail); - try { - const token = localStorage.getItem('authToken'); - const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/role`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, - body: JSON.stringify({ role: newRole }) + body: JSON.stringify({ role }), }); - if (!response.ok) { - throw new Error('Failed to update user role'); + if (data) { + showToast('User approved successfully!', 'success'); + fetchUsers(); + } else { + showToast('Failed to approve user', 'error'); } - - // Refresh users list - await fetchUsers(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update user role'); - } finally { - setUpdatingUser(null); + } catch (error) { + showToast('Error approving user', 'error'); } }; - const deleteUser = async (userEmail: string, userName: string) => { - if (!confirm(`Are you sure you want to delete user "${userName}"? This action cannot be undone.`)) { - return; - } + const handleReject = async (userEmail: string) => { + if (!confirm('Are you sure you want to reject this user?')) return; try { const token = localStorage.getItem('authToken'); - const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}`, { - method: 'DELETE', + const { data } = await apiCall(`/auth/users/${userEmail}/reject`, { + method: 'POST', headers: { 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - throw new Error('Failed to delete user'); - } - - // Refresh users list - await fetchUsers(); - await fetchPendingUsers(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete user'); - } - }; - - const approveUser = async (userEmail: string) => { - setUpdatingUser(userEmail); - try { - const token = localStorage.getItem('authToken'); - const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/approval`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'approved' }) }); - if (!response.ok) { - throw new Error('Failed to approve user'); + if (data) { + showToast('User rejected', 'success'); + fetchUsers(); + } else { + showToast('Failed to reject user', 'error'); } - - // Refresh both lists - await fetchUsers(); - await fetchPendingUsers(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to approve user'); - } finally { - setUpdatingUser(null); + } catch (error) { + showToast('Error rejecting user', 'error'); } }; - const denyUser = async (userEmail: string, userName: string) => { - if (!confirm(`Are you sure you want to deny access for "${userName}"?`)) { - return; - } + const handleDeactivate = async (userEmail: string) => { + if (!confirm('Are you sure you want to deactivate this user?')) return; - setUpdatingUser(userEmail); try { const token = localStorage.getItem('authToken'); - const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/approval`, { - method: 'PATCH', + const { data } = await apiCall(`/auth/users/${userEmail}/deactivate`, { + method: 'POST', headers: { 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'denied' }) }); - if (!response.ok) { - throw new Error('Failed to deny user'); + if (data) { + showToast('User deactivated', 'success'); + fetchUsers(); + } else { + showToast('Failed to deactivate user', 'error'); } - - // Refresh both lists - await fetchUsers(); - await fetchPendingUsers(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to deny user'); - } finally { - setUpdatingUser(null); + } catch (error) { + showToast('Error deactivating user', 'error'); } }; - useEffect(() => { - fetchUsers(); - fetchPendingUsers(); - }, []); + const handleReactivate = async (userEmail: string) => { + try { + const token = localStorage.getItem('authToken'); + const { data } = await apiCall(`/auth/users/${userEmail}/reactivate`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); - useEffect(() => { - if (activeTab === 'pending') { - fetchPendingUsers(); - } - }, [activeTab]); - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - const getRoleBadgeColor = (role: string) => { - switch (role) { - case 'administrator': - return 'bg-red-100 text-red-800 border-red-200'; - case 'coordinator': - return 'bg-blue-100 text-blue-800 border-blue-200'; - case 'driver': - return 'bg-green-100 text-green-800 border-green-200'; - default: - return 'bg-gray-100 text-gray-800 border-gray-200'; + if (data) { + showToast('User reactivated', 'success'); + fetchUsers(); + } else { + showToast('Failed to reactivate user', 'error'); + } + } catch (error) { + showToast('Error reactivating user', 'error'); } }; + const handleRoleChange = async (userEmail: string, newRole: string) => { + try { + const token = localStorage.getItem('authToken'); + const { data } = await apiCall(`/auth/users/${userEmail}/role`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ role: newRole }), + }); + + if (data) { + showToast('Role updated successfully', 'success'); + fetchUsers(); + setShowEditModal(false); + } else { + showToast('Failed to update role', 'error'); + } + } catch (error) { + showToast('Error updating role', 'error'); + } + }; + + // Filter users + const filteredUsers = users.filter(user => { + const matchesSearch = searchTerm === '' || + user.name.toLowerCase().includes(searchTerm.toLowerCase()) || + user.email.toLowerCase().includes(searchTerm.toLowerCase()) || + user.organization?.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesRole = filterRole === 'all' || user.role === filterRole; + const matchesStatus = filterStatus === 'all' || user.status === filterStatus; + + return matchesSearch && matchesRole && matchesStatus; + }); + + // Separate pending users + const pendingUsers = filteredUsers.filter(u => u.status === 'pending'); + const activeUsers = filteredUsers.filter(u => u.status !== 'pending'); + if (loading) { return ( -
-
-
-
- {[1, 2, 3].map(i => ( -
- ))} -
-
+
+
); } return ( -
-
-

User Management

-

Manage user accounts and permissions (PostgreSQL Database)

-
- - {error && ( -
-

{error}

- -
- )} - - {/* Tab Navigation */} -
-
-
-
-
- ))} -
- - {users.length === 0 && ( -
- No users found. -
- )} -
- )} - - {/* Pending Users Tab */} - {activeTab === 'pending' && ( -
-
-

+ {/* Pending Users */} + {pendingUsers.length > 0 && ( +
+
+

Pending Approval ({pendingUsers.length})

-

- Users waiting for administrator approval to access the system -

- -
- {pendingUsers.map((user) => ( -
-
-
- {user.picture ? ( - {user.name} - ) : ( -
- - {user.name.charAt(0).toUpperCase()} - -
- )} - +
+ {pendingUsers.map(user => ( +
+
+
+
+ + {user.name.charAt(0).toUpperCase()} + +
-

{user.name}

-

{user.email}

-
- Requested: {formatDate(user.created_at)} - via {user.provider} - - {user.role} - +

{user.name}

+

{user.email}

+
+

Organization: {user.organization || 'Not provided'}

+

Phone: {user.phone || 'Not provided'}

+

Requested Role: + {user.onboardingData?.requestedRole} +

+

+ Reason: {user.onboardingData?.reason} +

+ {user.onboardingData?.vehicleType && ( +
+

Driver Details:

+

+ Vehicle: {user.onboardingData.vehicleType} + ({user.onboardingData.vehicleCapacity} passengers) - + {user.onboardingData.licensePlate} +

+
+ )}
- -
-
))}
- - {pendingUsers.length === 0 && ( -
-
โœ…
-

No pending approvals

-

All users have been processed.

-
- )}
)} -
-

Role Descriptions:

-
    -
  • Administrator: Full access to all features including user management
  • -
  • Coordinator: Can manage VIPs, drivers, and schedules
  • -
  • Driver: Can view assigned schedules and update status
  • -
+ {/* Active/All Users */} +
+
+

+ Users ({activeUsers.length}) +

+
+
+ + + + + + + + + + + + + {activeUsers.map(user => ( + + + + + + + + + ))} + +
+ User + + Role + + Organization + + Status + + Approved By + + Actions +
+
+
+ + {user.name.charAt(0).toUpperCase()} + +
+
+
{user.name}
+
{user.email}
+
+
+
+ + {user.role} + + + {user.organization || '-'} + + + {user.status} + + + {user.approvedBy || '-'} + + + {user.status === 'active' ? ( + + ) : ( + + )} +
+
-
-

๐Ÿ” User Approval System:

-

- New users (except the first administrator) require approval before accessing the system. - Users with pending approval will see a "pending approval" message when they try to sign in. -

-
- -
-

โœ… PostgreSQL Database:

-

- User data is stored in your PostgreSQL database with proper indexing and relationships. - All user management operations are transactional and fully persistent across server restarts. -

-
+ {/* Edit Modal */} + {showEditModal && selectedUser && ( +
+
+

+ Edit User: {selectedUser.name} +

+
+
+ + +
+ +
+

Audit Information:

+

Created: {new Date(selectedUser.createdAt || '').toLocaleString()}

+ {selectedUser.approvedBy && ( +

Approved by: {selectedUser.approvedBy}

+ )} + {selectedUser.approvedAt && ( +

Approved at: {new Date(selectedUser.approvedAt).toLocaleString()}

+ )} + {selectedUser.lastLogin && ( +

Last login: {new Date(selectedUser.lastLogin).toLocaleString()}

+ )} +
+
+ +
+ +
+
+
+ )}
); }; -export default UserManagement; +export default UserManagement; \ No newline at end of file diff --git a/frontend/src/components/VipForm.tsx b/frontend/src/components/VipForm.tsx index 15f6448..ea08343 100644 --- a/frontend/src/components/VipForm.tsx +++ b/frontend/src/components/VipForm.tsx @@ -1,23 +1,13 @@ import React, { useState } from 'react'; +import { VipFormData } from '../types'; +import { useToast } from '../contexts/ToastContext'; interface Flight { flightNumber: string; flightDate: string; segment: number; validated?: boolean; - validationData?: any; -} - -interface VipFormData { - name: string; - organization: string; - department: 'Office of Development' | 'Admin'; - transportMode: 'flight' | 'self-driving'; - flights?: Flight[]; - expectedArrival?: string; - needsAirportPickup?: boolean; - needsVenueTransport: boolean; - notes: string; + validationData?: Record; } interface VipFormProps { @@ -26,6 +16,7 @@ interface VipFormProps { } const VipForm: React.FC = ({ onSubmit, onCancel }) => { + const { showToast } = useToast(); const [formData, setFormData] = useState({ name: '', organization: '', diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index e82e8ab..d0e983a 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -1,13 +1,79 @@ // API Configuration -// VITE_API_URL must be set at build time - no fallback to prevent production issues -export const API_BASE_URL = (import.meta as any).env.VITE_API_URL; +// Use relative URLs by default so it works with any domain/reverse proxy +export const API_BASE_URL = (import.meta as any).env.VITE_API_URL || ''; -if (!API_BASE_URL) { - throw new Error('VITE_API_URL environment variable is required'); +// API Error class +export class ApiError extends Error { + constructor( + message: string, + public status?: number, + public code?: string, + public details?: unknown + ) { + super(message); + this.name = 'ApiError'; + } } -// Helper function for API calls -export const apiCall = (endpoint: string, options?: RequestInit) => { +// Helper function for API calls with error handling +export const apiCall = async (endpoint: string, options?: RequestInit) => { const url = endpoint.startsWith('/') ? `${API_BASE_URL}${endpoint}` : endpoint; - return fetch(url, options); + + // Get auth token from localStorage + const authToken = localStorage.getItem('authToken'); + + // Build headers + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options?.headers, + }; + + // Add authorization header if token exists + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + try { + const response = await fetch(url, { + ...options, + headers, + }); + + // Handle non-2xx responses + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch { + errorData = { error: { message: response.statusText } }; + } + + throw new ApiError( + errorData.error?.message || `Request failed with status ${response.status}`, + response.status, + errorData.error?.code, + errorData.error?.details + ); + } + + // Try to parse JSON response + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + return { response, data }; + } + + return { response, data: null }; + } catch (error) { + // Network errors or other issues + if (error instanceof ApiError) { + throw error; + } + + throw new ApiError( + error instanceof Error ? error.message : 'Network request failed', + undefined, + 'NETWORK_ERROR' + ); + } }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 4d7ddbd..62f1c4c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,6 @@ -@import "tailwindcss"; +@tailwind base; +@tailwind components; +@tailwind utilities; /* Custom base styles */ @layer base { @@ -10,341 +12,81 @@ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; + + /* Color scheme variables */ + color-scheme: light dark; + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-secondary: #10b981; + --color-secondary-hover: #059669; + --color-danger: #ef4444; + --color-danger-hover: #dc2626; + --color-text: #1f2937; + --color-text-secondary: #6b7280; + --color-bg: #ffffff; + --color-bg-secondary: #f9fafb; + --color-border: #e5e7eb; } + @media (prefers-color-scheme: dark) { + :root { + --color-text: #f9fafb; + --color-text-secondary: #d1d5db; + --color-bg: #111827; + --color-bg-secondary: #1f2937; + --color-border: #374151; + } + } + * { + margin: 0; + padding: 0; box-sizing: border-box; } - + body { - margin: 0; - min-width: 320px; + color: var(--color-text); + background-color: var(--color-bg); min-height: 100vh; - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); - color: #1e293b; } - - #root { - width: 100%; - margin: 0 auto; - text-align: left; + + h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.3; + margin-bottom: 0.5em; } - - /* Smooth scrolling */ - html { - scroll-behavior: smooth; + + a { + color: var(--color-primary); + text-decoration: none; + transition: color 0.2s; } - - /* Focus styles */ - *:focus { - outline: 2px solid #3b82f6; - outline-offset: 2px; + + a:hover { + color: var(--color-primary-hover); } } -/* Custom component styles */ -@layer components { - /* Modern Button Styles */ - .btn { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - margin-bottom: 1.5rem; - } - - .form-label { - display: block; - font-size: 0.875rem; - font-weight: 600; - color: #334155; - margin-bottom: 0.75rem; - } - - .form-input { - 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 { - 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 { - 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 { - 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 { - width: 1rem; - height: 1rem; - color: #2563eb; - border: 1px solid #cbd5e1; - } - - .form-radio:focus { - ring: 2px; - ring-color: #3b82f6; - } - - /* Modal Styles */ - .modal-overlay { - 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 { - 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 { - background: linear-gradient(to right, #eff6ff, #eef2ff); - padding: 1.5rem 2rem; - border-bottom: 1px solid rgba(226, 232, 240, 0.6); - } - - .modal-body { - padding: 2rem; - } - - .modal-footer { - 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 { - 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 { - 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 { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; - } - - .form-section-title { - font-size: 1.125rem; - font-weight: 700; - color: #1e293b; - } - - /* Radio Group */ - .radio-group { - display: flex; - gap: 1.5rem; - margin-top: 0.75rem; - } - - .radio-option { - 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 { - border-color: #3b82f6; - background-color: #eff6ff; - ring: 2px; - ring-color: #bfdbfe; - } - - /* Checkbox Group */ - .checkbox-option { - 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 { - border-color: #3b82f6; - background-color: #eff6ff; - } +/* Smooth scrolling */ +html { + scroll-behavior: smooth; } + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--color-bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--color-text-secondary); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-text); +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3d7150d..e9060ef 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,15 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' import './index.css' +import { ErrorBoundary } from './components/ErrorBoundary' +import { ToastProvider } from './contexts/ToastContext' ReactDOM.createRoot(document.getElementById('root')!).render( - + + + + + , ) diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index f1c393d..e3e2cb3 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -1,7 +1,17 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; import { generateTestVips, getTestOrganizations, generateVipSchedule } from '../utils/testVipData'; +import { useToast } from '../contexts/ToastContext'; +import { LoadingSpinner } from '../components/LoadingSpinner'; +import UserManagement from '../components/UserManagement'; + +interface User { + id: string; + email: string; + name: string; + role: string; +} interface ApiKeys { aviationStackKey?: string; @@ -18,58 +28,45 @@ interface SystemSettings { notificationsEnabled?: boolean; } +type TabType = 'users' | 'integrations' | 'settings' | 'test-data' | 'api-docs'; + const AdminDashboard: React.FC = () => { const navigate = useNavigate(); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [adminPassword, setAdminPassword] = useState(''); + const { showToast } = useToast(); + const [user, setUser] = useState(null); + const [activeTab, setActiveTab] = useState('users'); const [apiKeys, setApiKeys] = useState({}); const [systemSettings, setSystemSettings] = useState({}); const [testResults, setTestResults] = useState<{ [key: string]: string }>({}); const [loading, setLoading] = useState(false); - const [saveStatus, setSaveStatus] = useState(null); const [showKeys, setShowKeys] = useState<{ [key: string]: boolean }>({}); const [savedKeys, setSavedKeys] = useState<{ [key: string]: boolean }>({}); const [testDataLoading, setTestDataLoading] = useState(false); - const [testDataStatus, setTestDataStatus] = useState(null); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); useEffect(() => { - // Check if already authenticated - const authStatus = sessionStorage.getItem('adminAuthenticated'); - if (authStatus === 'true') { - setIsAuthenticated(true); - loadSettings(); - } - }, []); - - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); + // Check if user is authenticated and has admin role + const authToken = localStorage.getItem('authToken'); + const userData = localStorage.getItem('user'); - 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'); + if (!authToken || !userData) { + navigate('/'); + return; } - }; + + const parsedUser = JSON.parse(userData); + if (parsedUser.role !== 'administrator' && parsedUser.role !== 'coordinator') { + navigate('/dashboard'); + return; + } + + setUser(parsedUser); + loadSettings(); + }, [navigate]); const loadSettings = async () => { try { - const response = await fetch('/api/admin/settings', { - headers: { - 'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || '' - } - }); + const response = await apiCall('/api/admin/settings'); if (response.ok) { const data = await response.json(); @@ -98,11 +95,13 @@ const AdminDashboard: React.FC = () => { } } catch (error) { console.error('Failed to load settings:', error); + showToast('Failed to load settings', 'error'); } }; const handleApiKeyChange = (key: keyof ApiKeys, value: string) => { setApiKeys(prev => ({ ...prev, [key]: value })); + setHasUnsavedChanges(true); // If user is typing a new key, mark it as not saved anymore if (value && !value.startsWith('***')) { setSavedKeys(prev => ({ ...prev, [key]: false })); @@ -111,17 +110,19 @@ const AdminDashboard: React.FC = () => { const handleSettingChange = (key: keyof SystemSettings, value: any) => { setSystemSettings(prev => ({ ...prev, [key]: value })); + setHasUnsavedChanges(true); }; const testApiConnection = async (apiType: string) => { setTestResults(prev => ({ ...prev, [apiType]: 'Testing...' })); try { - const response = await fetch(`/api/admin/test-api/${apiType}`, { + const token = localStorage.getItem('authToken'); + const response = await apiCall(`/api/admin/test-api/${apiType}`, { method: 'POST', headers: { + 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', - 'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || '' }, body: JSON.stringify({ apiKey: apiKeys[apiType as keyof ApiKeys] @@ -135,30 +136,33 @@ const AdminDashboard: React.FC = () => { ...prev, [apiType]: `Success: ${result.message}` })); + showToast('API connection successful!', 'success'); } else { setTestResults(prev => ({ ...prev, [apiType]: `Failed: ${result.error}` })); + showToast('API connection failed', 'error'); } } catch (error) { setTestResults(prev => ({ ...prev, [apiType]: 'Connection error' })); + showToast('Connection error', 'error'); } }; const saveSettings = async () => { setLoading(true); - setSaveStatus(null); try { - const response = await fetch('/api/admin/settings', { + const token = localStorage.getItem('authToken'); + const response = await apiCall('/api/admin/settings', { method: 'POST', headers: { + 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', - 'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || '' }, body: JSON.stringify({ apiKeys, @@ -167,7 +171,8 @@ const AdminDashboard: React.FC = () => { }); if (response.ok) { - setSaveStatus('Settings saved successfully!'); + showToast('Settings saved successfully!', 'success'); + setHasUnsavedChanges(false); // Mark keys as saved if they have values const newSavedKeys: { [key: string]: boolean } = {}; Object.entries(apiKeys).forEach(([key, value]) => { @@ -178,27 +183,18 @@ const AdminDashboard: React.FC = () => { setSavedKeys(prev => ({ ...prev, ...newSavedKeys })); // Clear the input fields after successful save setApiKeys({}); - setTimeout(() => setSaveStatus(null), 3000); } else { - setSaveStatus('Failed to save settings'); + showToast('Failed to save settings', 'error'); } } catch (error) { - setSaveStatus('Error saving settings'); + showToast('Error saving settings', 'error'); } finally { setLoading(false); } }; - const handleLogout = () => { - sessionStorage.removeItem('adminAuthenticated'); - setIsAuthenticated(false); - navigate('/'); - }; - - // Test VIP functions const createTestVips = async () => { setTestDataLoading(true); - setTestDataStatus('Creating test VIPs and schedules...'); try { const token = localStorage.getItem('authToken'); @@ -207,7 +203,6 @@ const AdminDashboard: React.FC = () => { let vipSuccessCount = 0; let vipErrorCount = 0; let scheduleSuccessCount = 0; - let scheduleErrorCount = 0; const createdVipIds: string[] = []; // First, create all VIPs @@ -228,16 +223,12 @@ const AdminDashboard: React.FC = () => { vipSuccessCount++; } else { vipErrorCount++; - console.error(`Failed to create VIP: ${vipData.name}`); } } catch (error) { vipErrorCount++; - console.error(`Error creating VIP ${vipData.name}:`, error); } } - setTestDataStatus(`Created ${vipSuccessCount} VIPs, now creating schedules...`); - // Then, create schedules for each successfully created VIP for (let i = 0; i < createdVipIds.length; i++) { const vipId = createdVipIds[i]; @@ -264,27 +255,21 @@ const AdminDashboard: React.FC = () => { if (scheduleResponse.ok) { scheduleSuccessCount++; - } else { - scheduleErrorCount++; - console.error(`Failed to create schedule event for ${vipData.name}: ${event.title}`); } } catch (error) { - scheduleErrorCount++; - console.error(`Error creating schedule event for ${vipData.name}:`, error); + console.error(`Error creating schedule event:`, error); } } } catch (error) { - console.error(`Error generating schedule for ${vipData.name}:`, error); + console.error(`Error generating schedule:`, error); } } - setTestDataStatus(`โœ… Created ${vipSuccessCount} VIPs with ${scheduleSuccessCount} schedule events! ${vipErrorCount > 0 || scheduleErrorCount > 0 ? `(${vipErrorCount + scheduleErrorCount} failed)` : ''}`); + showToast(`Created ${vipSuccessCount} VIPs with ${scheduleSuccessCount} schedule events!`, 'success'); } catch (error) { - setTestDataStatus('โŒ Failed to create test VIPs and schedules'); - console.error('Error creating test data:', error); + showToast('Failed to create test VIPs', 'error'); } finally { setTestDataLoading(false); - setTimeout(() => setTestDataStatus(null), 8000); } }; @@ -294,7 +279,6 @@ const AdminDashboard: React.FC = () => { } setTestDataLoading(true); - setTestDataStatus('Removing test VIPs...'); try { const token = localStorage.getItem('authToken'); @@ -318,7 +302,6 @@ const AdminDashboard: React.FC = () => { const testVips = allVips.filter((vip: any) => testOrganizations.includes(vip.organization)); let successCount = 0; - let errorCount = 0; for (const vip of testVips) { try { @@ -332,502 +315,446 @@ const AdminDashboard: React.FC = () => { if (deleteResponse.ok) { successCount++; - } else { - errorCount++; - console.error(`Failed to delete VIP: ${vip.name}`); } } catch (error) { - errorCount++; console.error(`Error deleting VIP ${vip.name}:`, error); } } - setTestDataStatus(`๐Ÿ—‘๏ธ Removed ${successCount} test VIPs successfully! ${errorCount > 0 ? `(${errorCount} failed)` : ''}`); + showToast(`Removed ${successCount} test VIPs successfully!`, 'success'); } catch (error) { - setTestDataStatus('โŒ Failed to remove test VIPs'); - console.error('Error removing test VIPs:', error); + showToast('Failed to remove test VIPs', 'error'); } finally { setTestDataLoading(false); - setTimeout(() => setTestDataStatus(null), 5000); } }; - if (!isAuthenticated) { + if (!user) { return (
-
-
-
-
-
-
-
-

Admin Login

-

Enter your admin password to continue

-
- -
-
- - setAdminPassword(e.target.value)} - className="form-input" - placeholder="Enter admin password" - required - /> -
- -
-
+
); } + const tabs = [ + { id: 'users' as TabType, label: 'Users', icon: '๐Ÿ‘ฅ' }, + { id: 'integrations' as TabType, label: 'Integrations', icon: '๐Ÿ”Œ' }, + { id: 'settings' as TabType, label: 'System Settings', icon: 'โš™๏ธ' }, + { id: 'test-data' as TabType, label: 'Test Data', icon: '๐Ÿงช' }, + { id: 'api-docs' as TabType, label: 'API Docs', icon: '๐Ÿ“š' }, + ]; + return ( -
- {/* Header */} -
-
-
-

- Admin Dashboard -

-

System configuration and API management

-
-
+
+ {/* Fixed Header */} +
+
+
+
+

Admin Dashboard

+

Manage system configuration and settings

+
- +
+ + {/* Tab Navigation */} +
+ {tabs.map(tab => ( + + ))}
- {/* API Keys Section */} -
-
-

API Key Management

-

Configure external service integrations

-
- -
- {/* AviationStack API */} -
-
-

AviationStack API

- {savedKeys.aviationStackKey && ( - - Configured - - )} -
- -
-
- -
- handleApiKeyChange('aviationStackKey', e.target.value)} - className="form-input pr-12" - /> - {savedKeys.aviationStackKey && ( - - )} + {/* Tab Content */} +
+ {/* Users Tab */} + {activeTab === 'users' && ( + + )} + + {/* Integrations Tab */} + {activeTab === 'integrations' && ( +
+ {/* Flight Tracking */} +
+
+

Flight Tracking

+

Configure AviationStack API for real-time flight tracking

+
+
+
+
+ +
+
+ handleApiKeyChange('aviationStackKey', e.target.value)} + className="form-input w-full" + /> + {savedKeys.aviationStackKey && ( + + )} +
+ +
+

+ Get your API key from aviationstack.com +

+ {testResults.aviationStackKey && ( +
+ {testResults.aviationStackKey} +
+ )} +
+
+
+
+ + {/* Google OAuth */} +
+
+

Google OAuth

+

Configure Google authentication for user login

+
+
+
+
+ + handleApiKeyChange('googleClientId', e.target.value)} + className="form-input w-full" + /> +
+
+ + handleApiKeyChange('googleClientSecret', e.target.value)} + className="form-input w-full" + /> +
+
+ +
+

Quick Setup Guide

+
    +
  1. Go to Google Cloud Console
  2. +
  3. Create a new project or select existing
  4. +
  5. Enable Google+ API
  6. +
  7. Create OAuth 2.0 credentials
  8. +
  9. Add authorized redirect URIs
  10. +
+
+
+
+ + {/* Coming Soon */} +
+
+
+
+

Google Maps

+ + Coming Soon + +
+

Real-time location tracking and route optimization

-

- Get your key from: https://aviationstack.com/dashboard -

-
+
+
+
+

Twilio SMS

+ + Coming Soon + +
+

SMS notifications for VIPs and drivers

+
+
+
+
+ )} + + {/* System Settings Tab */} + {activeTab === 'settings' && ( +
+
+

System Configuration

+

Configure default system behavior and preferences

+
+
+
+
+ + handleSettingChange('defaultPickupLocation', e.target.value)} + placeholder="e.g., JFK Airport Terminal 4" + className="form-input w-full" + /> +
+ +
+ + handleSettingChange('defaultDropoffLocation', e.target.value)} + placeholder="e.g., Hilton Downtown" + className="form-input w-full" + /> +
+ +
+ + +
+ +
+ +
+
+
+
+ )} + + {/* Test Data Tab */} + {activeTab === 'test-data' && ( +
+
+
+ โš ๏ธ +
+

Test Environment Only

+

These tools create realistic test data for development and testing purposes.

+
+
+
+ +
+
+
+
+ ๐ŸŽญ +
+

Create Test VIPs

+

+ Generate 20 diverse test VIPs with realistic profiles +

+
    +
  • โ€ข 10 Admin department VIPs
  • +
  • โ€ข 10 Office of Development VIPs
  • +
  • โ€ข Mix of flight and self-driving
  • +
  • โ€ข Realistic schedules included
  • +
+ +
+
+ +
+
+
+ ๐Ÿ—‘๏ธ +
+

Remove Test VIPs

+

+ Clean up all test VIPs from the system +

+
+

+ This will permanently delete all VIPs from test organizations. Real VIPs will not be affected. +

+
+ +
+
+
+
+ )} + + {/* API Docs Tab */} + {activeTab === 'api-docs' && ( +
+
+
+
+ ๐Ÿ“– +
+

Interactive API Docs

+

+ Explore and test all API endpoints with Swagger UI +

- -
- {testResults.aviationStackKey && ( -
- {testResults.aviationStackKey} +
+ +
+
+
+ ๐Ÿš€ +
+

Quick Start Examples

+
+
+ GET /api/health +
+
+ GET /api/vips +
+
+ GET /api/drivers +
+
+ GET /api/flights/UA1234
- )} -
-
-
- - {/* Google OAuth Credentials */} -
-
-

Google OAuth Credentials

- {(savedKeys.googleClientId && savedKeys.googleClientSecret) && ( - - Configured - - )} -
- -
-
- -
- handleApiKeyChange('googleClientId', e.target.value)} - className="form-input pr-12" - /> - {savedKeys.googleClientId && ( - - )} -
-
- -
- -
- handleApiKeyChange('googleClientSecret', e.target.value)} - className="form-input pr-12" - /> - {savedKeys.googleClientSecret && ( - - )}
- -
-

Setup Instructions

-
    -
  1. Go to Google Cloud Console
  2. -
  3. Create or select a project
  4. -
  5. Enable the Google+ API
  6. -
  7. Go to "Credentials" โ†’ "Create Credentials" โ†’ "OAuth 2.0 Client IDs"
  8. -
  9. Set authorized redirect URI: https://your-domain.com/auth/google/callback
  10. -
  11. Set authorized JavaScript origins: https://your-domain.com
  12. -
-
-
- - {/* Future APIs */} -
-
-
-

Google Maps API

- - Coming Soon - -
- -
- -
-
-

Twilio API

- - Coming Soon - -
- -
-
-
-
- - {/* System Settings Section */} -
-
-

System Settings

-

Configure default system behavior

-
- -
-
-
- - handleSettingChange('defaultPickupLocation', e.target.value)} - placeholder="e.g., JFK Airport Terminal 4" - className="form-input" - /> -
- -
- - handleSettingChange('defaultDropoffLocation', e.target.value)} - placeholder="e.g., Hilton Downtown" - className="form-input" - /> -
- -
- - -
- -
-
- handleSettingChange('notificationsEnabled', e.target.checked)} - className="form-checkbox mr-3" - /> - Enable Email/SMS Notifications -
-
-
-
-
- - {/* Test VIP Data Section */} -
-
-

Test VIP Data Management

-

Create and manage test VIP data for application testing

-
- -
-
-
-

Create Test VIPs

-

- Generate 20 diverse test VIPs (10 Admin department, 10 Office of Development) with realistic data including flights, transport modes, and special requirements. -

-
    -
  • โ€ข Mixed flight and self-driving transport modes
  • -
  • โ€ข Single flights, connecting flights, and multi-segment journeys
  • -
  • โ€ข Diverse organizations and special requirements
  • -
  • โ€ข Realistic arrival dates (tomorrow and day after)
  • -
- -
- -
-

Remove Test VIPs

-

- Remove all test VIPs from the system. This will delete VIPs from the following test organizations: -

-
-
- {getTestOrganizations().slice(0, 8).map(org => ( -
โ€ข {org}
- ))} -
... and 12 more organizations
-
-
- -
-
- - {testDataStatus && ( -
- {testDataStatus} -
- )} - -
-

๐Ÿ’ก Test Data Details

-
-

Admin Department (10 VIPs): University officials, ambassadors, ministers, and executives

-

Office of Development (10 VIPs): Donors, foundation leaders, and philanthropists

-

Transport Modes: Mix of flights (single, connecting, multi-segment) and self-driving

-

Special Requirements: Dietary restrictions, accessibility needs, security details, interpreters

-

Full Day Schedules: Each VIP gets 5-7 realistic events including meetings, meals, tours, and presentations

-

Schedule Types: Airport pickup, welcome breakfast, department meetings, working lunches, campus tours, receptions

-
-
-
-
- - {/* API Documentation Section */} -
-
-

API Documentation

-

Developer resources and API testing

-
- -
-
-
-

Interactive API Documentation

-

- Explore and test all API endpoints with the interactive Swagger UI documentation. -

- -

- Opens in a new tab with full endpoint documentation and testing capabilities -

-
- -
-

Quick API Examples

-
-
- Health Check: - GET /api/health -
-
- Get VIPs: - GET /api/vips -
-
- Get Drivers: - GET /api/drivers -
-
- Flight Info: - GET /api/flights/UA1234 -
-
- -
-
- -
-

- Pro Tip: The interactive documentation allows you to test API endpoints directly in your browser. - Perfect for developers integrating with the VIP Coordinator system! -

-
-
-
- - {/* Save Button */} -
- - - {saveStatus && ( -
- {saveStatus}
)}
+ + {/* Sticky Save Bar */} + {hasUnsavedChanges && ( +
+
+
+
+ + Unsaved Changes + + You have unsaved configuration changes +
+
+ + +
+
+
+
+ )}
); }; -export default AdminDashboard; +export default AdminDashboard; \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 461a918..931f315 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; interface ScheduleEvent { id: string; @@ -83,28 +83,27 @@ const Dashboard: React.FC = () => { 'Content-Type': 'application/json' }; - const [vipsResponse, driversResponse] = await Promise.all([ + const [vipsResult, driversResult] = await Promise.all([ apiCall('/api/vips', { headers: authHeaders }), apiCall('/api/drivers', { headers: authHeaders }) ]); - if (!vipsResponse.ok || !driversResponse.ok) { + const vipsData = vipsResult.data; + const driversData = driversResult.data; + + if (!vipsData || !driversData) { throw new Error('Failed to fetch data'); } - const vipsData = await vipsResponse.json(); - const driversData = await driversResponse.json(); - // Fetch schedule for each VIP and determine current/next events const vipsWithSchedules = await Promise.all( vipsData.map(async (vip: Vip) => { try { - const scheduleResponse = await apiCall(`/api/vips/${vip.id}/schedule`, { + const { data: scheduleData } = await apiCall(`/api/vips/${vip.id}/schedule`, { headers: authHeaders }); - if (scheduleResponse.ok) { - const scheduleData = await scheduleResponse.json(); + if (scheduleData) { const currentEvent = getCurrentEvent(scheduleData); const nextEvent = getNextEvent(scheduleData); diff --git a/frontend/src/pages/DriverDashboard.tsx b/frontend/src/pages/DriverDashboard.tsx index 6b13c53..8b1eac6 100644 --- a/frontend/src/pages/DriverDashboard.tsx +++ b/frontend/src/pages/DriverDashboard.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; import GanttChart from '../components/GanttChart'; interface DriverScheduleEvent { @@ -42,15 +42,14 @@ const DriverDashboard: React.FC = () => { const fetchDriverSchedule = async () => { try { const token = localStorage.getItem('authToken'); - const response = await apiCall(`/api/drivers/${driverId}/schedule`, { + const { data } = await apiCall(`/api/drivers/${driverId}/schedule`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (response.ok) { - const data = await response.json(); + if (data) { setScheduleData(data); } else { setError('Driver not found'); diff --git a/frontend/src/pages/DriverList.tsx b/frontend/src/pages/DriverList.tsx index 9230baf..687a115 100644 --- a/frontend/src/pages/DriverList.tsx +++ b/frontend/src/pages/DriverList.tsx @@ -1,23 +1,19 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; import DriverForm from '../components/DriverForm'; import EditDriverForm from '../components/EditDriverForm'; - -interface Driver { - id: string; - name: string; - phone: string; - currentLocation: { lat: number; lng: number }; - assignedVipIds: string[]; - vehicleCapacity?: number; -} +import { Driver, DriverFormData } from '../types'; +import { useToast } from '../contexts/ToastContext'; +import { LoadingSpinner } from '../components/LoadingSpinner'; const DriverList: React.FC = () => { + const { showToast } = useToast(); const [drivers, setDrivers] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingDriver, setEditingDriver] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); // Function to extract last name for sorting const getLastName = (fullName: string) => { @@ -38,19 +34,18 @@ const DriverList: React.FC = () => { const fetchDrivers = async () => { try { const token = localStorage.getItem('authToken'); - const response = await apiCall('/api/drivers', { + const { data } = await apiCall('/api/drivers', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (response.ok) { - const data = await response.json(); + if (data) { const sortedDrivers = sortDriversByLastName(data); setDrivers(sortedDrivers); } else { - console.error('Failed to fetch drivers:', response.status); + console.error('Failed to fetch drivers'); } } catch (error) { console.error('Error fetching drivers:', error); @@ -62,7 +57,7 @@ const DriverList: React.FC = () => { fetchDrivers(); }, []); - const handleAddDriver = async (driverData: any) => { + const handleAddDriver = async (driverData: DriverFormData) => { try { const token = localStorage.getItem('authToken'); const response = await apiCall('/api/drivers', { @@ -78,15 +73,18 @@ const DriverList: React.FC = () => { const newDriver = await response.json(); setDrivers(prev => sortDriversByLastName([...prev, newDriver])); setShowForm(false); + showToast('Driver added successfully!', 'success'); } else { console.error('Failed to add driver:', response.status); + showToast('Failed to add driver. Please try again.', 'error'); } } catch (error) { console.error('Error adding driver:', error); + showToast('An error occurred while adding the driver.', 'error'); } }; - const handleEditDriver = async (driverData: any) => { + const handleEditDriver = async (driverData: DriverFormData) => { try { const token = localStorage.getItem('authToken'); const response = await apiCall(`/api/drivers/${driverData.id}`, { @@ -104,11 +102,14 @@ const DriverList: React.FC = () => { driver.id === updatedDriver.id ? updatedDriver : driver ))); setEditingDriver(null); + showToast('Driver updated successfully!', 'success'); } else { console.error('Failed to update driver:', response.status); + showToast('Failed to update driver. Please try again.', 'error'); } } catch (error) { console.error('Error updating driver:', error); + showToast('An error occurred while updating the driver.', 'error'); } }; @@ -129,30 +130,39 @@ const DriverList: React.FC = () => { if (response.ok) { setDrivers(prev => prev.filter(driver => driver.id !== driverId)); + showToast('Driver deleted successfully!', 'success'); } else { console.error('Failed to delete driver:', response.status); + showToast('Failed to delete driver. Please try again.', 'error'); } } catch (error) { console.error('Error deleting driver:', error); + showToast('An error occurred while deleting the driver.', 'error'); } }; if (loading) { return ( -
-
-
- Loading drivers... -
+
+
); } + // Filter drivers based on search term + const filteredDrivers = drivers.filter(driver => { + const searchLower = searchTerm.toLowerCase(); + return ( + driver.name.toLowerCase().includes(searchLower) || + driver.phone.toLowerCase().includes(searchLower) + ); + }); + return (
{/* Header */}
-
+

Driver Management @@ -171,16 +181,53 @@ const DriverList: React.FC = () => {

+ + {/* Search Bar */} +
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-3 pl-12 border border-slate-200 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all" + /> + + + + {searchTerm && ( + + )} +
+ {/* Search Results */} + {searchTerm && ( +
+

+ Found {filteredDrivers.length} result{filteredDrivers.length !== 1 ? 's' : ''} for "{searchTerm}" +

+
+ )} + {/* Driver Grid */} - {drivers.length === 0 ? ( + {filteredDrivers.length === 0 ? (
-

No Drivers Found

-

Get started by adding your first driver

+

+ {searchTerm ? 'No Drivers Found' : 'No Drivers Added Yet'} +

+

+ {searchTerm ? `No drivers match your search for "${searchTerm}"` : 'Get started by adding your first driver'} +

) : (
- {drivers.map((driver) => ( + {filteredDrivers.map((driver) => (
{/* Driver Header */} diff --git a/frontend/src/pages/VipDetails.tsx b/frontend/src/pages/VipDetails.tsx index e04df14..14ce4aa 100644 --- a/frontend/src/pages/VipDetails.tsx +++ b/frontend/src/pages/VipDetails.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; import FlightStatus from '../components/FlightStatus'; import ScheduleManager from '../components/ScheduleManager'; @@ -37,15 +37,14 @@ const VipDetails: React.FC = () => { const fetchVip = async () => { try { const token = localStorage.getItem('authToken'); - const response = await apiCall('/api/vips', { + const { data: vips } = await apiCall('/api/vips', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (response.ok) { - const vips = await response.json(); + if (vips) { const foundVip = vips.find((v: Vip) => v.id === id); if (foundVip) { @@ -74,15 +73,14 @@ const VipDetails: React.FC = () => { if (vip) { try { const token = localStorage.getItem('authToken'); - const response = await apiCall(`/api/vips/${vip.id}/schedule`, { + const { data: scheduleData } = await apiCall(`/api/vips/${vip.id}/schedule`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (response.ok) { - const scheduleData = await response.json(); + if (scheduleData) { setSchedule(scheduleData); } } catch (error) { diff --git a/frontend/src/pages/VipList.tsx b/frontend/src/pages/VipList.tsx index bd00319..3b1add0 100644 --- a/frontend/src/pages/VipList.tsx +++ b/frontend/src/pages/VipList.tsx @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { apiCall } from '../config/api'; +import { apiCall } from '../utils/api'; import VipForm from '../components/VipForm'; import EditVipForm from '../components/EditVipForm'; import FlightStatus from '../components/FlightStatus'; +import { useToast } from '../contexts/ToastContext'; +import { LoadingSpinner } from '../components/LoadingSpinner'; interface Vip { id: string; @@ -26,10 +28,13 @@ interface Vip { } const VipList: React.FC = () => { + const { showToast } = useToast(); const [vips, setVips] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingVip, setEditingVip] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); // Function to extract last name for sorting const getLastName = (fullName: string) => { @@ -50,19 +55,18 @@ const VipList: React.FC = () => { const fetchVips = async () => { try { const token = localStorage.getItem('authToken'); - const response = await apiCall('/api/vips', { + const { data } = await apiCall('/api/vips', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (response.ok) { - const data = await response.json(); + if (data) { const sortedVips = sortVipsByLastName(data); setVips(sortedVips); } else { - console.error('Failed to fetch VIPs:', response.status); + console.error('Failed to fetch VIPs'); } } catch (error) { console.error('Error fetching VIPs:', error); @@ -75,6 +79,7 @@ const VipList: React.FC = () => { }, []); const handleAddVip = async (vipData: any) => { + setSubmitting(true); try { const token = localStorage.getItem('authToken'); const response = await apiCall('/api/vips', { @@ -90,11 +95,15 @@ const VipList: React.FC = () => { const newVip = await response.json(); setVips(prev => sortVipsByLastName([...prev, newVip])); setShowForm(false); + showToast('VIP added successfully!', 'success'); } else { - console.error('Failed to add VIP:', response.status); + showToast('Failed to add VIP. Please try again.', 'error'); } } catch (error) { console.error('Error adding VIP:', error); + showToast('An error occurred while adding the VIP.', 'error'); + } finally { + setSubmitting(false); } }; @@ -114,11 +123,13 @@ const VipList: React.FC = () => { const updatedVip = await response.json(); setVips(prev => sortVipsByLastName(prev.map(vip => vip.id === updatedVip.id ? updatedVip : vip))); setEditingVip(null); + showToast('VIP updated successfully!', 'success'); } else { - console.error('Failed to update VIP:', response.status); + showToast('Failed to update VIP. Please try again.', 'error'); } } catch (error) { console.error('Error updating VIP:', error); + showToast('An error occurred while updating the VIP.', 'error'); } }; @@ -139,30 +150,42 @@ const VipList: React.FC = () => { if (response.ok) { setVips(prev => prev.filter(vip => vip.id !== vipId)); + showToast('VIP deleted successfully!', 'success'); } else { - console.error('Failed to delete VIP:', response.status); + showToast('Failed to delete VIP. Please try again.', 'error'); } } catch (error) { console.error('Error deleting VIP:', error); + showToast('An error occurred while deleting the VIP.', 'error'); } }; if (loading) { return ( -
-
-
- Loading VIPs... -
+
+
); } + // Filter VIPs based on search term + const filteredVips = vips.filter(vip => { + const searchLower = searchTerm.toLowerCase(); + return ( + vip.name.toLowerCase().includes(searchLower) || + vip.organization.toLowerCase().includes(searchLower) || + vip.department.toLowerCase().includes(searchLower) || + (vip.transportMode === 'flight' && vip.flights?.some(flight => + flight.flightNumber.toLowerCase().includes(searchLower) + )) + ); + }); + return (
{/* Header */}
-
+

VIP Management @@ -176,16 +199,52 @@ const VipList: React.FC = () => { Add New VIP

+ + {/* Search Bar */} +
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-3 pl-12 border border-slate-200 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all" + /> + + + + {searchTerm && ( + + )} +
{/* VIP List */} - {vips.length === 0 ? ( + {searchTerm && ( +
+

+ Found {filteredVips.length} result{filteredVips.length !== 1 ? 's' : ''} for "{searchTerm}" +

+
+ )} + + {filteredVips.length === 0 ? (
-

No VIPs Found

-

Get started by adding your first VIP

+

+ {searchTerm ? 'No VIPs Found' : 'No VIPs Added Yet'} +

+

+ {searchTerm ? `No VIPs match your search for "${searchTerm}"` : 'Get started by adding your first VIP'} +

) : (
- {vips.map((vip) => ( + {filteredVips.map((vip) => (
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 002884a..31db060 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,36 +14,45 @@ export default defineConfig({ port: 5173, allowedHosts: [ 'localhost', - '127.0.0.1' + '127.0.0.1', + 'bsa.madeamess.online', + '.madeamess.online' // Allow all subdomains ], + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups' + }, proxy: { '/api': { - target: 'http://backend:3000', + target: 'http://127.0.0.1:3000', changeOrigin: true, }, // Only proxy specific auth endpoints, not the callback route '/auth/setup': { - target: 'http://backend:3000', + target: 'http://127.0.0.1:3000', changeOrigin: true, }, '/auth/google/url': { - target: 'http://backend:3000', + target: 'http://127.0.0.1:3000', changeOrigin: true, }, '/auth/google/exchange': { - target: 'http://backend:3000', + target: 'http://127.0.0.1:3000', + changeOrigin: true, + }, + '/auth/google/verify': { + target: 'http://127.0.0.1:3000', changeOrigin: true, }, '/auth/me': { - target: 'http://backend:3000', + target: 'http://127.0.0.1:3000', changeOrigin: true, }, '/auth/logout': { - target: 'http://backend:3000', + target: 'http://127.0.0.1:3000', changeOrigin: true, }, '/auth/status': { - target: 'http://backend:3000', + target: 'http://127.0.0.1:3000', changeOrigin: true, }, },