diff --git a/.env.example b/.env.example index f02da76..9543133 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,26 @@ +# VIP Coordinator Environment Configuration +# Copy this file to .env and update the values for your deployment + # Database Configuration -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 +DB_PASSWORD=VipCoord2025SecureDB -# Redis Configuration -REDIS_URL=redis://redis:6379 +# Domain Configuration (Update these for your domain) +DOMAIN=your-domain.com +VITE_API_URL=https://api.your-domain.com -# 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 +# 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 -# JWT Configuration -JWT_SECRET=your_jwt_secret_here_minimum_32_characters_long +# Frontend URL +FRONTEND_URL=https://your-domain.com -# Environment -NODE_ENV=development +# Admin Configuration +ADMIN_PASSWORD=ChangeThisSecurePassword -# API Configuration -API_PORT=3000 +# Flight API Configuration (Optional) +AVIATIONSTACK_API_KEY=your-aviationstack-api-key -# 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 +# Port Configuration +PORT=3000 \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index f658162..0f079a9 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,232 +1,266 @@ # ๐Ÿš€ VIP Coordinator - Docker Hub Deployment Guide -## ๐Ÿ“‹ Quick Start +Deploy the VIP Coordinator application on any system with Docker in just a few steps! -### Prerequisites -- Docker and Docker Compose installed -- Google Cloud Console account (for OAuth setup) +## ๐Ÿ“‹ Prerequisites -### 1. Download and Configure +- **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: ```bash -# Pull the project -git clone +mkdir vip-coordinator cd vip-coordinator -# Copy environment template -cp .env.example .env.prod - -# Edit with your configuration -nano .env.prod +# 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 ``` -### 2. Required Configuration - -Edit `.env.prod` with your values: +### 2. Configure Environment ```bash -# Database Configuration -DB_PASSWORD=your-secure-database-password +# Copy the environment template +cp .env.example .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 +# Edit the configuration (use your preferred editor) +nano .env ``` -### 3. Google OAuth Setup +**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 -1. **Create Google Cloud Project**: - - Go to [Google Cloud Console](https://console.cloud.google.com/) - - Create a new project +**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`) -2. **Enable Google+ API**: - - Navigate to "APIs & Services" > "Library" - - Search for "Google+ API" and enable it +### 3. Set Up Google OAuth -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` +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 -### 4. Deploy +### 4. Deploy the Application ```bash +# Pull the latest images from Docker Hub +docker-compose pull + # Start the application -docker-compose -f docker-compose.prod.yml up -d +docker-compose up -d # Check status -docker-compose -f docker-compose.prod.yml ps - -# View logs -docker-compose -f docker-compose.prod.yml logs -f +docker-compose ps ``` -### 5. Access Your Application +### 5. Access the Application -- **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 +- **Local Development**: http://localhost +- **Production**: https://your-domain.com -### 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 +## ๐Ÿ”ง Configuration Options ### Environment Variables -| 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!` | +| 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 | -### Optional Configuration +### Ports -- **AviationStack API Key**: Configure via admin interface for flight tracking -- **Custom Ports**: Modify docker-compose.prod.yml if needed +- **Frontend**: Port 80 (HTTP) +- **Backend**: Port 3000 (API) +- **Database**: Internal only (PostgreSQL) +- **Redis**: Internal only (Cache) -## ๐Ÿ—๏ธ Architecture +## ๐ŸŒ Production Deployment -### 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 +### With Reverse Proxy (Recommended) -### 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 +For production, use a reverse proxy like Nginx or Traefik: -## ๐Ÿ” Security Best Practices +```nginx +# Nginx configuration example +server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; +} -### 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 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; + } +} -### 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 +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; + } +} +``` -## ๐Ÿšจ Troubleshooting +### 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 ### Common Issues -**OAuth Not Working**: +**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 + ```bash -# Check Google OAuth configuration -docker-compose -f docker-compose.prod.yml logs backend | grep -i oauth +# View all logs +docker-compose logs -# Verify redirect URI matches exactly in Google Console -``` +# View specific service logs +docker-compose logs backend +docker-compose logs frontend +docker-compose logs db -**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 +# Follow logs in real-time +docker-compose logs -f backend ``` ### Health Checks ```bash -# Check all service health -docker-compose -f docker-compose.prod.yml ps +# Check container status +docker-compose ps -# Test API health endpoint -curl http://localhost:3000/api/health +# Check backend health +curl http://localhost:3000/health -# Test frontend +# Check 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 -```bash -# View all logs -docker-compose -f docker-compose.prod.yml logs +Logs are automatically rotated and can be viewed using Docker commands. -# 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 -``` +## ๐Ÿ” Security Considerations -## ๐Ÿ”„ Updates and Maintenance +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** -### Updating the Application +## ๐Ÿ“ž Support -```bash -# Pull latest changes -git pull origin main +If you encounter issues: -# Rebuild and restart -docker-compose -f docker-compose.prod.yml down -docker-compose -f docker-compose.prod.yml up -d --build -``` +1. Check the troubleshooting section above +2. Review container logs +3. Verify your configuration +4. Check GitHub issues for known problems -### Backup Database +## ๐ŸŽ‰ Success! -```bash -# Create database backup -docker-compose -f docker-compose.prod.yml exec db pg_dump -U postgres vip_coordinator > backup.sql +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 -# 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 +The first user to log in will automatically become the system administrator. \ No newline at end of file diff --git a/Makefile b/Makefile index 52529dd..4a9f5d2 100644 --- a/Makefile +++ b/Makefile @@ -1,74 +1,10 @@ -.PHONY: dev build deploy test test-backend test-frontend test-e2e test-coverage clean help +.PHONY: dev build deploy -# 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 e3343d9..9b970bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,14 +7,7 @@ "start": "node dist/index.js", "dev": "npx tsx src/index.ts", "build": "tsc", - "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" + "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ "vip", @@ -28,25 +21,18 @@ "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", - "zod": "^3.22.4" + "uuid": "^9.0.0" }, "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 c460a3f..ccad26f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,325 +1,868 @@ -import express from 'express'; -import cors from 'cors'; +import express, { Express, Request, Response } from 'express'; import dotenv from 'dotenv'; -// import authService from './services/authService'; // Replaced by simpleAuth +import cors from 'cors'; import authRoutes, { requireAuth, requireRole } from './routes/simpleAuth'; -import dataService from './services/unifiedDataService'; -import { validate, schemas } from './middleware/simpleValidation'; -import { errorHandler, notFoundHandler } from './middleware/errorHandler'; +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 jwtKeyManager from './services/jwtKeyManager'; // Initialize JWT Key Manager dotenv.config(); -// 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; +const app: Express = express(); +const port: number = process.env.PORT ? parseInt(process.env.PORT) : 3000; // Middleware app.use(cors({ origin: [ process.env.FRONTEND_URL || 'http://localhost:5173', - 'https://bsa.madeamess.online' + '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) ], 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')); -// Health check -app.get('/api/health', (req, res) => { - res.json({ - status: 'OK', - timestamp: new Date().toISOString(), - version: '2.0.0' // Simplified version +// 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' }); }); -// Auth routes - using simpleAuth with JWT Key Manager -app.use('/auth', authRoutes); - -// OLD AUTH ROUTES - COMMENTED OUT -/* -app.get('/auth/setup', async (req, res) => { +app.post('/api/admin/jwt-rotate', requireAuth, requireRole(['administrator']), (req: Request, res: Response) => { + const jwtKeyManager = require('./services/jwtKeyManager').default; + try { - // Check if any users exist in the system - const userCount = await dataService.getUserCount(); + jwtKeyManager.forceRotation(); res.json({ - needsSetup: userCount === 0, - hasUsers: userCount > 0 + success: true, + message: 'JWT key rotation triggered successfully. New tokens will use the new key.' }); } catch (error) { - console.error('Error in /auth/setup:', error); - res.status(500).json({ error: 'Failed to check setup status' }); + res.status(500).json({ error: 'Failed to rotate JWT keys' }); } }); -app.get('/auth/google', (req, res) => { - res.redirect('/auth/google'); -}); - -app.get('/auth/google/url', (req, res) => { +// Initialize database and start server +async function startServer() { 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 + // 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`); }); - } -}); - -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' }); + console.error('โŒ Failed to start server:', error); + process.exit(1); } -}); +} -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 +startServer(); diff --git a/backend/src/routes/simpleAuth.ts b/backend/src/routes/simpleAuth.ts index 082760e..a947b21 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: Record = {}) { +function logAuthEvent(event: string, details: any = {}) { 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 isFirstUser = await databaseService.isFirstUser(); - const role = isFirstUser ? 'administrator' : 'coordinator'; + const approvedUserCount = await databaseService.getApprovedUserCount(); + const role = approvedUserCount === 0 ? 'administrator' : 'coordinator'; logAuthEvent('USER_CREATION', { email: googleUser.email, role, - is_first_user: isFirstUser + is_first_user: approvedUserCount === 0 }); user = await databaseService.createUser({ @@ -292,12 +292,13 @@ router.get('/google/callback', async (req: Request, res: Response) => { email: googleUser.email, name: googleUser.name, profile_picture_url: googleUser.picture, - role, - status: isFirstUser ? 'active' : 'pending' + role }); - // Log the user creation - if (isFirstUser) { + // Auto-approve first admin, others need approval + if (approvedUserCount === 0) { + await databaseService.updateUserApprovalStatus(googleUser.email, 'approved'); + user.approval_status = 'approved'; logAuthEvent('FIRST_ADMIN_CREATED', { email: googleUser.email }); } else { logAuthEvent('USER_PENDING_APPROVAL', { email: googleUser.email }); @@ -313,9 +314,9 @@ router.get('/google/callback', async (req: Request, res: Response) => { }); } - // 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 }); + // Check if user is approved + if (user.approval_status !== 'approved') { + logAuthEvent('USER_NOT_APPROVED', { email: user.email, status: user.approval_status }); return res.redirect(`${frontendUrl}?error=pending_approval&message=Your account is pending administrator approval`); } @@ -364,8 +365,8 @@ router.post('/google/exchange', async (req: Request, res: Response) => { if (!user) { // Determine role - first user becomes admin - const isFirstUser = await databaseService.isFirstUser(); - const role = isFirstUser ? 'administrator' : 'coordinator'; + const userCount = await databaseService.getUserCount(); + const role = userCount === 0 ? 'administrator' : 'coordinator'; user = await databaseService.createUser({ id: googleUser.id, @@ -373,30 +374,14 @@ router.post('/google/exchange', async (req: Request, res: Response) => { email: googleUser.email, name: googleUser.name, profile_picture_url: googleUser.picture, - role, - status: isFirstUser ? 'active' : 'pending' + role }); - - // 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); @@ -408,8 +393,7 @@ router.post('/google/exchange', async (req: Request, res: Response) => { email: user.email, name: user.name, picture: user.profile_picture_url, - role: user.role, - status: user.status + role: user.role } }); @@ -436,115 +420,6 @@ 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; @@ -735,143 +610,4 @@ 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 e9dbc09..ddb5434 100644 --- a/backend/src/services/databaseService.ts +++ b/backend/src/services/databaseService.ts @@ -1,332 +1,550 @@ import { Pool, PoolClient } from 'pg'; import { createClient, RedisClientType } from 'redis'; -// 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; +class DatabaseService { + private pool: Pool; + private redis: RedisClientType; constructor() { - this.backupService = backupDatabaseService; + 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); + } } - // Delegate all existing methods to backup service async query(text: string, params?: any[]): Promise { - return this.backupService.query(text, params); + const client = await this.pool.connect(); + try { + const result = await client.query(text, params); + return result; + } finally { + client.release(); + } } async getClient(): Promise { - return this.backupService.getClient(); + return await this.pool.connect(); } async close(): Promise { - return this.backupService.close(); + await this.pool.end(); + if (this.redis.isOpen) { + await this.redis.disconnect(); + } } + // Initialize database tables async initializeTables(): Promise { - return this.backupService.initializeTables(); + 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; + } } - // User methods from backup service - async createUser(user: any): Promise { - return this.backupService.createUser(user); + // 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]; } async getUserByEmail(email: string): Promise { - return this.backupService.getUserByEmail(email); + const query = 'SELECT * FROM users WHERE email = $1'; + const result = await this.query(query, [email]); + return result.rows[0] || null; } async getUserById(id: string): Promise { - return this.backupService.getUserById(id); - } - - async updateUserRole(email: string, role: string): Promise { - return this.backupService.updateUserRole(email, role); - } - - async updateUserLastSignIn(email: string): Promise { - return this.backupService.updateUserLastSignIn(email); - } - - async getUserCount(): Promise { - 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(); + const query = 'SELECT * FROM users WHERE id = $1'; + const result = await this.query(query, [id]); + return result.rows[0] || null; } 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]); + const query = 'SELECT * FROM users ORDER BY created_at ASC'; + const result = await this.query(query); 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++; + 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}`); } - - 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; + return result.rows[0] || null; } - // Fix for first user admin issue - async getActiveUserCount(): Promise { - const query = "SELECT COUNT(*) as count FROM users WHERE status = 'active'"; + 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; + } + + async getUserCount(): Promise { + const query = 'SELECT COUNT(*) as count FROM users'; const result = await this.query(query); return parseInt(result.rows[0].count); } - async isFirstUser(): Promise { - return this.backupService.isFirstUser(); + // 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; } - // VIP methods from backup service - async createVip(vip: any): Promise { - return this.backupService.createVip(vip); + 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; } - async getVipById(id: string): Promise { - return this.backupService.getVipById(id); + 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 getAllVips(): Promise { - return this.backupService.getAllVips(); + // 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 updateVip(id: string, vip: any): Promise { - return this.backupService.updateVip(id, vip); + // 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 deleteVip(id: string): Promise { - return this.backupService.deleteVip(id); + // 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 getVipsByDepartment(department: string): Promise { - return this.backupService.getVipsByDepartment(department); + 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); + } } - // Driver methods from backup service - async createDriver(driver: any): Promise { - return this.backupService.createDriver(driver); + 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 {}; + } } - 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); + 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); + } } } -// Export singleton instance -const databaseService = new EnhancedDatabaseService(); -export default databaseService; \ No newline at end of file +export default new DatabaseService(); diff --git a/backend/src/services/jwtKeyManager.ts b/backend/src/services/jwtKeyManager.ts index 019f3f2..1209a90 100644 --- a/backend/src/services/jwtKeyManager.ts +++ b/backend/src/services/jwtKeyManager.ts @@ -8,13 +8,10 @@ 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 { @@ -81,9 +78,6 @@ 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 }; @@ -108,10 +102,7 @@ class JWTKeyManager { email: decoded.email, name: decoded.name, profile_picture_url: decoded.profile_picture_url, - role: decoded.role, - status: decoded.status, - approval_status: decoded.approval_status, - onboardingData: decoded.onboardingData + role: decoded.role }; } catch (error) { // Try previous secret during grace period @@ -130,10 +121,7 @@ class JWTKeyManager { email: decoded.email, name: decoded.name, profile_picture_url: decoded.profile_picture_url, - role: decoded.role, - status: decoded.status, - approval_status: decoded.approval_status, - onboardingData: decoded.onboardingData + role: decoded.role }; } catch (gracePeriodError) { console.log('โŒ Token verification failed with both current and previous keys'); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index c511b28..05f269c 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -17,5 +17,5 @@ "moduleResolution": "node" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.original.ts", "src/**/backup-services/**", "src/routes/simpleAuth.ts", "src/config/simpleAuth.ts"] + "exclude": ["node_modules", "dist"] } diff --git a/deploy.sh b/deploy.sh index 5779f92..32b7e08 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,139 +1,130 @@ #!/bin/bash -# VIP Coordinator Quick Deploy Script -# This script helps you deploy the VIP Coordinator application using Docker +# VIP Coordinator - Quick Deployment Script +# This script helps you deploy VIP Coordinator with Docker set -e -echo "๐Ÿš€ VIP Coordinator Deployment Script" -echo "====================================" +echo "๐Ÿš€ VIP Coordinator - Quick 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 "โœ… Created .env file from .env.example" + 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 "" - 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..." + read -p "Press Enter after you've updated the .env file..." else - echo "โŒ .env.example file not found. Please create a .env file manually." + echo "โŒ .env.example file not found. Please ensure you have the deployment files." exit 1 fi fi # Validate required environment variables -echo "๐Ÿ” Validating environment configuration..." +echo "๐Ÿ” Validating configuration..." -# Source the .env file -set -a source .env -set +a -# Check required variables -REQUIRED_VARS=("POSTGRES_PASSWORD" "GOOGLE_CLIENT_ID" "GOOGLE_CLIENT_SECRET") -MISSING_VARS=() +if [ -z "$DB_PASSWORD" ] || [ "$DB_PASSWORD" = "VipCoord2025SecureDB" ]; then + echo "โš ๏ธ Warning: Please change DB_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 "$ADMIN_PASSWORD" ] || [ "$ADMIN_PASSWORD" = "ChangeThisSecurePassword" ]; then + echo "โš ๏ธ Warning: Please change ADMIN_PASSWORD from the default value" +fi -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." +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" exit 1 fi -echo "โœ… Environment configuration looks good!" +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 -# Pull the latest images -echo "" -echo "๐Ÿ“ฅ Pulling latest Docker images..." -docker pull t72chevy/vip-coordinator:backend-latest -docker pull t72chevy/vip-coordinator:frontend-latest +echo "โœ… Configuration validated" -# Stop existing containers if running -echo "" -echo "๐Ÿ›‘ Stopping existing containers (if any)..." -docker-compose down --remove-orphans || true +# Pull latest images +echo "๐Ÿ“ฅ Pulling latest images from Docker Hub..." +docker-compose pull # Start the application -echo "" -echo "๐Ÿš€ Starting VIP Coordinator application..." +echo "๐Ÿš€ Starting VIP Coordinator..." docker-compose up -d -# Wait for services to be healthy -echo "" +# Wait for services to be ready echo "โณ Waiting for services to start..." sleep 10 # Check service status -echo "" -echo "๐Ÿ“Š Service Status:" +echo "๐Ÿ” Checking service status..." docker-compose ps -# Check if services are healthy -echo "" -echo "๐Ÿฅ Health Checks:" +# 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 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 +# Check if frontend is accessible +echo "๐ŸŒ Checking frontend..." +if curl -s http://localhost/ > /dev/null 2>&1; then echo "โœ… Frontend is accessible" else - echo "โš ๏ธ Frontend health check failed (may still be starting up)" + echo "โš ๏ธ Frontend check failed, but this might be normal during startup" fi echo "" -echo "๐ŸŽ‰ Deployment complete!" -echo "" -echo "๐Ÿ“ฑ Access your application:" +echo "๐ŸŽ‰ VIP Coordinator deployment completed!" +echo "=============================================" +echo "๐Ÿ“ Access your application:" echo " Frontend: http://localhost" echo " Backend API: http://localhost:3000" -echo " Health Check: http://localhost:3000/health" echo "" -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 "๐Ÿ“‹ 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 "" -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 +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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2d81475..0b3e5f1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,9 +5,8 @@ services: db: image: postgres:15 environment: - POSTGRES_DB: ${POSTGRES_DB:-vip_coordinator} - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: vip_coordinator + POSTGRES_PASSWORD: changeme volumes: - postgres-data:/var/lib/postgresql/data ports: @@ -23,14 +22,8 @@ services: context: ./backend target: development environment: - 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} + DATABASE_URL: postgresql://postgres:changeme@db:5432/vip_coordinator + REDIS_URL: redis://redis:6379 ports: - 3000:3000 depends_on: @@ -45,8 +38,7 @@ services: context: ./frontend target: development environment: - VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api} - VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + VITE_API_URL: http://localhost:3000/api ports: - 5173:5173 depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 288b553..014903c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,88 +1,57 @@ version: '3.8' services: - postgres: - image: postgres:15-alpine + + db: + image: postgres:15 environment: - POSTGRES_DB: ${POSTGRES_DB:-vip_coordinator} - POSTGRES_USER: ${POSTGRES_USER:-vip_user} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: vip_coordinator + POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" + - postgres-data:/var/lib/postgresql/data restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vip_user} -d ${POSTGRES_DB:-vip_coordinator}"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - vip-network + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 30s + timeout: 10s + retries: 3 redis: - image: redis:7-alpine - ports: - - "6379:6379" + image: redis:7 restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - vip-network + interval: 30s + timeout: 10s + retries: 3 backend: image: t72chevy/vip-coordinator:backend-latest environment: - - 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} + 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 ports: - "3000:3000" - restart: unless-stopped depends_on: - postgres: + db: condition: service_healthy redis: condition: service_healthy - 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 + restart: unless-stopped 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" - 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 + - backend + restart: unless-stopped volumes: - postgres_data: - driver: local - -networks: - vip-network: - driver: bridge \ No newline at end of file + postgres-data: \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index e45eaee..e68ec3b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,8 +5,6 @@ VIP Coordinator Dashboard - -
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7841a3d..cec5403 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.21", + "autoprefixer": "^10.4.14", "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", - "lightningcss": "^1.30.1", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.17", + "postcss": "^8.5.4", + "tailwindcss": "^4.1.8", "typescript": "^5.6.0", "vite": "^5.4.10" }, @@ -38,8 +38,6 @@ }, "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": { @@ -963,22 +961,15 @@ "url": "https://github.com/sponsors/nzakas" } }, - "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==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", "dev": true, "license": "ISC", "dependencies": { - "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" + "minipass": "^7.0.4" }, "engines": { - "node": ">=12" + "node": ">=18.0.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1062,17 +1053,6 @@ "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", @@ -1374,6 +1354,74 @@ "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, @@ -1753,19 +1801,6 @@ "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, @@ -1780,34 +1815,6 @@ "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", @@ -1817,8 +1824,6 @@ }, "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": [ { @@ -1858,19 +1863,6 @@ "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", @@ -1935,16 +1927,6 @@ "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, @@ -1979,42 +1961,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/chownr": { + "version": "3.0.0", "dev": true, - "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" - }, + "license": "BlueOak-1.0.0", "engines": { - "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": ">=18" } }, "node_modules/color-convert": { @@ -2033,16 +1985,6 @@ "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, @@ -2066,19 +2008,6 @@ "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, @@ -2113,38 +2042,22 @@ "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/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "node_modules/enhanced-resolve": { + "version": "5.18.1", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } }, "node_modules/esbuild": { "version": "0.21.5", @@ -2551,23 +2464,6 @@ "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, @@ -2595,16 +2491,6 @@ "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, @@ -2613,27 +2499,6 @@ "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, @@ -2653,6 +2518,11 @@ "node": ">=4" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "dev": true, @@ -2666,19 +2536,6 @@ "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", @@ -2714,35 +2571,6 @@ "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, @@ -2751,16 +2579,6 @@ "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, @@ -2787,28 +2605,10 @@ "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" } @@ -2899,8 +2699,6 @@ }, "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": { @@ -2926,132 +2724,6 @@ "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": [ @@ -3071,89 +2743,6 @@ "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, @@ -3191,6 +2780,14 @@ "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", @@ -3233,31 +2830,42 @@ }, "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, @@ -3285,16 +2893,6 @@ "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, @@ -3303,26 +2901,6 @@ "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, @@ -3367,13 +2945,6 @@ "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", @@ -3403,37 +2974,6 @@ "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, @@ -3452,30 +2992,8 @@ "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.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.4", "dev": true, "funding": [ { @@ -3501,120 +3019,6 @@ "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, @@ -3728,50 +3132,6 @@ "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", @@ -3903,19 +3263,6 @@ "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, @@ -3924,110 +3271,6 @@ "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", @@ -4041,29 +3284,6 @@ "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, @@ -4075,88 +3295,41 @@ "node": ">=8" } }, - "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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "version": "4.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", "dev": true, "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "dev": true, + "license": "ISC", "dependencies": { - "@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" + "@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" }, "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, - "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==", + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", "dev": true, - "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" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=0.8" + "node": ">=18" } }, "node_modules/to-regex-range": { @@ -4185,13 +3358,6 @@ "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, @@ -4254,13 +3420,6 @@ "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", @@ -4343,119 +3502,11 @@ "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 67a9f58..908bc2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,10 +11,7 @@ "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", - "test": "vitest", - "test:ui": "vitest --ui", - "test:coverage": "vitest --coverage" + "preview": "vite preview" }, "dependencies": { "leaflet": "^1.9.4", @@ -24,27 +21,20 @@ "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", - "@vitest/coverage-v8": "^1.3.1", - "@vitest/ui": "^1.3.1", - "autoprefixer": "^10.4.21", + "autoprefixer": "^10.4.14", "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", - "jsdom": "^24.0.0", - "lightningcss": "^1.30.1", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.17", + "@tailwindcss/postcss": "^4.1.8", + "postcss": "^8.5.4", + "tailwindcss": "^4.1.8", "typescript": "^5.6.0", - "vite": "^5.4.10", - "vitest": "^1.3.1" + "vite": "^5.4.10" } } diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs index 52012d2..8416a01 100644 --- a/frontend/postcss.config.mjs +++ b/frontend/postcss.config.mjs @@ -1,6 +1,6 @@ export default { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, } } \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 2d7a4df..0b0ce05 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1 +1,187 @@ -/* Modern App-specific styles - Component classes moved to inline Tailwind */ \ No newline at end of file +/* 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; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bf3fdad..e5cb037 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,68 +1,57 @@ import { useState, useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; -import { apiCall } from './utils/api'; +import { apiCall } from './config/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'); - 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 + if (token) { apiCall('/auth/me', { headers: { 'Authorization': `Bearer ${token}` } }) - .then(({ data }) => { - if (data) { - setUser(data as User); - localStorage.setItem('user', JSON.stringify(data)); + .then(res => { + if (res.ok) { + return res.json(); } else { // Token is invalid, remove it localStorage.removeItem('authToken'); - localStorage.removeItem('user'); - setUser(null); + throw new Error('Invalid token'); } }) + .then(userData => { + setUser(userData); + setLoading(false); + }) .catch(error => { console.error('Auth check failed:', error); - localStorage.removeItem('authToken'); - localStorage.removeItem('user'); - setUser(null); + setLoading(false); }); } else { setLoading(false); } }, []); - const handleLogin = (userData: User) => { + const handleLogin = (userData: any) => { setUser(userData); }; const handleLogout = () => { localStorage.removeItem('authToken'); - localStorage.removeItem('user'); setUser(null); // Optionally call logout endpoint apiCall('/auth/logout', { method: 'POST' }) @@ -82,52 +71,13 @@ 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 dfd34c8..b6afbba 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 '../utils/api'; +import { apiCall } from '../config/api'; interface DriverAvailability { driverId: string; @@ -60,7 +60,7 @@ const DriverSelector: React.FC = ({ setLoading(true); try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall('/api/drivers/availability', { + const response = await apiCall('/api/drivers/availability', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -69,7 +69,8 @@ const DriverSelector: React.FC = ({ body: JSON.stringify(eventTime), }); - if (data) { + if (response.ok) { + const data = await response.json(); setAvailability(data); } } catch (error) { diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 4922abc..62832b1 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,58 +1,115 @@ import React, { useEffect, useState } from 'react'; -import { apiCall } from '../utils/api'; -import GoogleLogin from './GoogleLogin'; +import { apiCall } from '../config/api'; import './Login.css'; -import { User } from '../types'; interface LoginProps { - onLogin: (user: User) => void; -} - -interface SetupStatus { - ready: boolean; - hasUsers: boolean; - missingEnvVars?: string[]; + onLogin: (user: any) => void; } 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(({ data }) => { + .then(res => res.json()) + .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); }); - }, []); - 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); - }; + // 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 handleGoogleError = (errorMessage: string) => { - setError(errorMessage); - setTimeout(() => setError(null), 5000); // Clear error after 5 seconds + 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.'); + } }; if (loading) { return (
-
-

VIP Coordinator

-

Loading...

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

VIP Coordinator

-

Transportation Management System

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

VIP Coordinator

+

Secure access required

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

๐Ÿš€ First Time Setup

+

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

)}
- - -
- {setupStatus && !setupStatus.hasUsers && ( -

- First user to log in will become an administrator -

- )} + + +
+

+ {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. +

+
+ )} +
+ +
+

Secure authentication powered by Google OAuth

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

Access Denied

+

You need administrator privileges to access user management.

+
+ ); + } const fetchUsers = async () => { try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall('/auth/users', { + const response = await fetch(`${API_BASE_URL}/auth/users`, { headers: { 'Authorization': `Bearer ${token}`, - }, + 'Content-Type': 'application/json' + } }); - if (data) { - setUsers(data); - } else { - showToast('Failed to load users', 'error'); + if (!response.ok) { + throw new Error('Failed to fetch users'); } - } catch (error) { - showToast('Error loading users', 'error'); + + const userData = await response.json(); + setUsers(userData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch users'); } finally { setLoading(false); } }; - const handleApprove = async (userEmail: string, role: string) => { + const fetchPendingUsers = async () => { try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall(`/auth/users/${userEmail}/approve`, { - method: 'POST', + const response = await fetch(`${API_BASE_URL}/auth/users/pending/list`, { headers: { 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ role }), + 'Content-Type': 'application/json' + } }); - if (data) { - showToast('User approved successfully!', 'success'); - fetchUsers(); - } else { - showToast('Failed to approve user', 'error'); + if (!response.ok) { + throw new Error('Failed to fetch pending users'); } - } catch (error) { - showToast('Error approving user', 'error'); + + const pendingData = await response.json(); + setPendingUsers(pendingData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch pending users'); } }; - const handleReject = async (userEmail: string) => { - if (!confirm('Are you sure you want to reject this user?')) return; - + const updateUserRole = async (userEmail: string, newRole: string) => { + setUpdatingUser(userEmail); try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall(`/auth/users/${userEmail}/reject`, { - method: 'POST', + const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/role`, { + method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }) }); - if (data) { - showToast('User rejected', 'success'); - fetchUsers(); - } else { - showToast('Failed to reject user', 'error'); + if (!response.ok) { + throw new Error('Failed to update user role'); } - } catch (error) { - showToast('Error rejecting user', 'error'); + + // Refresh users list + await fetchUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update user role'); + } finally { + setUpdatingUser(null); } }; - const handleDeactivate = async (userEmail: string) => { - if (!confirm('Are you sure you want to deactivate this user?')) return; + const deleteUser = async (userEmail: string, userName: string) => { + if (!confirm(`Are you sure you want to delete user "${userName}"? This action cannot be undone.`)) { + return; + } try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall(`/auth/users/${userEmail}/deactivate`, { - method: 'POST', + const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}`, { + method: 'DELETE', headers: { 'Authorization': `Bearer ${token}`, - }, + 'Content-Type': 'application/json' + } }); - if (data) { - showToast('User deactivated', 'success'); - fetchUsers(); - } else { - showToast('Failed to deactivate user', 'error'); + if (!response.ok) { + throw new Error('Failed to delete user'); } - } catch (error) { - showToast('Error deactivating user', 'error'); + + // Refresh users list + await fetchUsers(); + await fetchPendingUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete user'); } }; - const handleReactivate = async (userEmail: string) => { + const approveUser = async (userEmail: string) => { + setUpdatingUser(userEmail); try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall(`/auth/users/${userEmail}/reactivate`, { - method: 'POST', + 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 (data) { - showToast('User reactivated', 'success'); - fetchUsers(); - } else { - showToast('Failed to reactivate user', 'error'); + if (!response.ok) { + throw new Error('Failed to approve user'); } - } catch (error) { - showToast('Error reactivating user', 'error'); + + // Refresh both lists + await fetchUsers(); + await fetchPendingUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to approve user'); + } finally { + setUpdatingUser(null); } }; - const handleRoleChange = async (userEmail: string, newRole: string) => { + const denyUser = async (userEmail: string, userName: string) => { + if (!confirm(`Are you sure you want to deny access for "${userName}"?`)) { + return; + } + + setUpdatingUser(userEmail); try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall(`/auth/users/${userEmail}/role`, { - method: 'PUT', + const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/approval`, { + method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: newRole }), + body: JSON.stringify({ status: 'denied' }) }); - if (data) { - showToast('Role updated successfully', 'success'); - fetchUsers(); - setShowEditModal(false); - } else { - showToast('Failed to update role', 'error'); + if (!response.ok) { + throw new Error('Failed to deny user'); } - } catch (error) { - showToast('Error updating role', 'error'); + + // Refresh both lists + await fetchUsers(); + await fetchPendingUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to deny user'); + } finally { + setUpdatingUser(null); } }; - // 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; - }); + useEffect(() => { + fetchUsers(); + fetchPendingUsers(); + }, []); - // Separate pending users - const pendingUsers = filteredUsers.filter(u => u.status === 'pending'); - const activeUsers = filteredUsers.filter(u => u.status !== 'pending'); + 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 (loading) { return ( -
- +
+
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
); } return ( -
- {/* Filters */} -
-
-
- setSearchTerm(e.target.value)} - className="form-input w-full" - /> -
-
- -
-
- -
+ โณ Pending Approval ({pendingUsers.length}) + {pendingUsers.length > 0 && ( + + {pendingUsers.length} + + )} + +
- {/* Pending Users */} - {pendingUsers.length > 0 && ( -
-
-

- Pending Approval ({pendingUsers.length}) + {/* Content based on active tab */} + {activeTab === 'all' && ( +
+
+

+ All Users ({users.length})

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

{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} -

-
+

{user.name}

+

{user.email}

+
+ Joined: {formatDate(user.created_at)} + {user.last_sign_in_at && ( + Last login: {formatDate(user.last_sign_in_at)} )} + via {user.provider}
-
- + +
+
+ Role: + +
+ + {user.email !== currentUser.email && ( + + )} + + {user.email === currentUser.email && ( + + ๐Ÿ‘ค You + + )} +
+
+
+ ))} +
+ + {users.length === 0 && ( +
+ No users found. +
+ )} +
+ )} + + {/* Pending Users Tab */} + {activeTab === 'pending' && ( +
+
+

+ 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()} + +
+ )} + +
+

{user.name}

+

{user.email}

+
+ Requested: {formatDate(user.created_at)} + via {user.provider} + + {user.role} + +
+
+
+ +
+
))}
+ + {pendingUsers.length === 0 && ( +
+
โœ…
+

No pending approvals

+

All users have been processed.

+
+ )}
)} - {/* 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' ? ( - - ) : ( - - )} -
-
+
+

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
  • +
- {/* 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()}

- )} -
-
- -
- -
-
-
- )} +
+

๐Ÿ” 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. +

+
); }; -export default UserManagement; \ No newline at end of file +export default UserManagement; diff --git a/frontend/src/components/VipForm.tsx b/frontend/src/components/VipForm.tsx index ea08343..15f6448 100644 --- a/frontend/src/components/VipForm.tsx +++ b/frontend/src/components/VipForm.tsx @@ -1,13 +1,23 @@ 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?: Record; + 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; } interface VipFormProps { @@ -16,7 +26,6 @@ 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 d0e983a..e82e8ab 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -1,79 +1,13 @@ // API Configuration -// 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 || ''; +// 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; -// 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'; - } +if (!API_BASE_URL) { + throw new Error('VITE_API_URL environment variable is required'); } -// Helper function for API calls with error handling -export const apiCall = async (endpoint: string, options?: RequestInit) => { +// Helper function for API calls +export const apiCall = (endpoint: string, options?: RequestInit) => { const url = endpoint.startsWith('/') ? `${API_BASE_URL}${endpoint}` : endpoint; - - // 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' - ); - } + return fetch(url, options); }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 62f1c4c..4d7ddbd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,6 +1,4 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; /* Custom base styles */ @layer base { @@ -12,81 +10,341 @@ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - - /* 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; + -webkit-text-size-adjust: 100%; } - @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 { - color: var(--color-text); - background-color: var(--color-bg); + margin: 0; + min-width: 320px; min-height: 100vh; + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); + color: #1e293b; } - - h1, h2, h3, h4, h5, h6 { + + #root { + width: 100%; + margin: 0 auto; + text-align: left; + } + + /* Smooth scrolling */ + html { + scroll-behavior: smooth; + } + + /* Focus styles */ + *:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } +} + +/* Custom 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; - line-height: 1.3; - margin-bottom: 0.5em; + 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); } - a { - color: var(--color-primary); - text-decoration: none; - transition: color 0.2s; + .btn:focus { + ring: 2px; + ring-offset: 2px; } - a:hover { - color: var(--color-primary-hover); + .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 e9060ef..3d7150d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,15 +2,9 @@ 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 e3e2cb3..f1c393d 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -1,17 +1,7 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { apiCall } from '../utils/api'; +import { apiCall } from '../config/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; @@ -28,45 +18,58 @@ interface SystemSettings { notificationsEnabled?: boolean; } -type TabType = 'users' | 'integrations' | 'settings' | 'test-data' | 'api-docs'; - const AdminDashboard: React.FC = () => { const navigate = useNavigate(); - const { showToast } = useToast(); - const [user, setUser] = useState(null); - const [activeTab, setActiveTab] = useState('users'); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [adminPassword, setAdminPassword] = useState(''); 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 [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [testDataStatus, setTestDataStatus] = useState(null); useEffect(() => { - // Check if user is authenticated and has admin role - const authToken = localStorage.getItem('authToken'); - const userData = localStorage.getItem('user'); - - if (!authToken || !userData) { - navigate('/'); - return; + // Check if already authenticated + const authStatus = sessionStorage.getItem('adminAuthenticated'); + if (authStatus === 'true') { + setIsAuthenticated(true); + loadSettings(); } + }, []); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); - const parsedUser = JSON.parse(userData); - if (parsedUser.role !== 'administrator' && parsedUser.role !== 'coordinator') { - navigate('/dashboard'); - return; + 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'); } - - setUser(parsedUser); - loadSettings(); - }, [navigate]); + }; const loadSettings = async () => { try { - const response = await apiCall('/api/admin/settings'); + const response = await fetch('/api/admin/settings', { + headers: { + 'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || '' + } + }); if (response.ok) { const data = await response.json(); @@ -95,13 +98,11 @@ 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 })); @@ -110,19 +111,17 @@ 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 token = localStorage.getItem('authToken'); - const response = await apiCall(`/api/admin/test-api/${apiType}`, { + const response = await fetch(`/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] @@ -136,33 +135,30 @@ 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 token = localStorage.getItem('authToken'); - const response = await apiCall('/api/admin/settings', { + const response = await fetch('/api/admin/settings', { method: 'POST', headers: { - 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', + 'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || '' }, body: JSON.stringify({ apiKeys, @@ -171,8 +167,7 @@ const AdminDashboard: React.FC = () => { }); if (response.ok) { - showToast('Settings saved successfully!', 'success'); - setHasUnsavedChanges(false); + setSaveStatus('Settings saved successfully!'); // Mark keys as saved if they have values const newSavedKeys: { [key: string]: boolean } = {}; Object.entries(apiKeys).forEach(([key, value]) => { @@ -183,18 +178,27 @@ const AdminDashboard: React.FC = () => { setSavedKeys(prev => ({ ...prev, ...newSavedKeys })); // Clear the input fields after successful save setApiKeys({}); + setTimeout(() => setSaveStatus(null), 3000); } else { - showToast('Failed to save settings', 'error'); + setSaveStatus('Failed to save settings'); } } catch (error) { - showToast('Error saving settings', 'error'); + setSaveStatus('Error saving settings'); } 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'); @@ -203,6 +207,7 @@ const AdminDashboard: React.FC = () => { let vipSuccessCount = 0; let vipErrorCount = 0; let scheduleSuccessCount = 0; + let scheduleErrorCount = 0; const createdVipIds: string[] = []; // First, create all VIPs @@ -223,12 +228,16 @@ 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]; @@ -255,21 +264,27 @@ const AdminDashboard: React.FC = () => { if (scheduleResponse.ok) { scheduleSuccessCount++; + } else { + scheduleErrorCount++; + console.error(`Failed to create schedule event for ${vipData.name}: ${event.title}`); } } catch (error) { - console.error(`Error creating schedule event:`, error); + scheduleErrorCount++; + console.error(`Error creating schedule event for ${vipData.name}:`, error); } } } catch (error) { - console.error(`Error generating schedule:`, error); + console.error(`Error generating schedule for ${vipData.name}:`, error); } } - showToast(`Created ${vipSuccessCount} VIPs with ${scheduleSuccessCount} schedule events!`, 'success'); + setTestDataStatus(`โœ… Created ${vipSuccessCount} VIPs with ${scheduleSuccessCount} schedule events! ${vipErrorCount > 0 || scheduleErrorCount > 0 ? `(${vipErrorCount + scheduleErrorCount} failed)` : ''}`); } catch (error) { - showToast('Failed to create test VIPs', 'error'); + setTestDataStatus('โŒ Failed to create test VIPs and schedules'); + console.error('Error creating test data:', error); } finally { setTestDataLoading(false); + setTimeout(() => setTestDataStatus(null), 8000); } }; @@ -279,6 +294,7 @@ const AdminDashboard: React.FC = () => { } setTestDataLoading(true); + setTestDataStatus('Removing test VIPs...'); try { const token = localStorage.getItem('authToken'); @@ -302,6 +318,7 @@ 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 { @@ -315,446 +332,502 @@ 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); } } - showToast(`Removed ${successCount} test VIPs successfully!`, 'success'); + setTestDataStatus(`๐Ÿ—‘๏ธ Removed ${successCount} test VIPs successfully! ${errorCount > 0 ? `(${errorCount} failed)` : ''}`); } catch (error) { - showToast('Failed to remove test VIPs', 'error'); + setTestDataStatus('โŒ Failed to remove test VIPs'); + console.error('Error removing test VIPs:', error); } finally { setTestDataLoading(false); + setTimeout(() => setTestDataStatus(null), 5000); } }; - if (!user) { + if (!isAuthenticated) { 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 ( -
- {/* Fixed Header */} -
-
-
-
-

Admin Dashboard

-

Manage system configuration and settings

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

+ Admin Dashboard +

+

System configuration and API management

+
+
-
- - {/* Tab Navigation */} -
- {tabs.map(tab => ( - - ))} +
- {/* 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} -
- )} -
-
-
+ {/* API Keys Section */} +
+
+

API Key Management

+

Configure external service integrations

+
+ +
+ {/* AviationStack API */} +
+
+

AviationStack API

+ {savedKeys.aviationStackKey && ( + + Configured + + )}
- - {/* 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

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

+ 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 -

+
-
- -
-
-
- ๐Ÿš€ -
-

Quick Start Examples

-
-
- GET /api/health + +
+ {testResults.aviationStackKey && ( +
+ {testResults.aviationStackKey}
-
- 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 + +
+ +
+
+
- {/* Sticky Save Bar */} - {hasUnsavedChanges && ( -
-
-
-
- - Unsaved Changes - - You have unsaved configuration changes -
-
- - + {/* 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} +
+ )} +
); }; -export default AdminDashboard; \ No newline at end of file +export default AdminDashboard; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 931f315..461a918 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 '../utils/api'; +import { apiCall } from '../config/api'; interface ScheduleEvent { id: string; @@ -83,27 +83,28 @@ const Dashboard: React.FC = () => { 'Content-Type': 'application/json' }; - const [vipsResult, driversResult] = await Promise.all([ + const [vipsResponse, driversResponse] = await Promise.all([ apiCall('/api/vips', { headers: authHeaders }), apiCall('/api/drivers', { headers: authHeaders }) ]); - const vipsData = vipsResult.data; - const driversData = driversResult.data; - - if (!vipsData || !driversData) { + if (!vipsResponse.ok || !driversResponse.ok) { 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 { data: scheduleData } = await apiCall(`/api/vips/${vip.id}/schedule`, { + const scheduleResponse = await apiCall(`/api/vips/${vip.id}/schedule`, { headers: authHeaders }); - if (scheduleData) { + if (scheduleResponse.ok) { + const scheduleData = await scheduleResponse.json(); const currentEvent = getCurrentEvent(scheduleData); const nextEvent = getNextEvent(scheduleData); diff --git a/frontend/src/pages/DriverDashboard.tsx b/frontend/src/pages/DriverDashboard.tsx index 8b1eac6..6b13c53 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 '../utils/api'; +import { apiCall } from '../config/api'; import GanttChart from '../components/GanttChart'; interface DriverScheduleEvent { @@ -42,14 +42,15 @@ const DriverDashboard: React.FC = () => { const fetchDriverSchedule = async () => { try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall(`/api/drivers/${driverId}/schedule`, { + const response = await apiCall(`/api/drivers/${driverId}/schedule`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (data) { + if (response.ok) { + const data = await response.json(); setScheduleData(data); } else { setError('Driver not found'); diff --git a/frontend/src/pages/DriverList.tsx b/frontend/src/pages/DriverList.tsx index 687a115..9230baf 100644 --- a/frontend/src/pages/DriverList.tsx +++ b/frontend/src/pages/DriverList.tsx @@ -1,19 +1,23 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { apiCall } from '../utils/api'; +import { apiCall } from '../config/api'; import DriverForm from '../components/DriverForm'; import EditDriverForm from '../components/EditDriverForm'; -import { Driver, DriverFormData } from '../types'; -import { useToast } from '../contexts/ToastContext'; -import { LoadingSpinner } from '../components/LoadingSpinner'; + +interface Driver { + id: string; + name: string; + phone: string; + currentLocation: { lat: number; lng: number }; + assignedVipIds: string[]; + vehicleCapacity?: number; +} 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) => { @@ -34,18 +38,19 @@ const DriverList: React.FC = () => { const fetchDrivers = async () => { try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall('/api/drivers', { + const response = await apiCall('/api/drivers', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (data) { + if (response.ok) { + const data = await response.json(); const sortedDrivers = sortDriversByLastName(data); setDrivers(sortedDrivers); } else { - console.error('Failed to fetch drivers'); + console.error('Failed to fetch drivers:', response.status); } } catch (error) { console.error('Error fetching drivers:', error); @@ -57,7 +62,7 @@ const DriverList: React.FC = () => { fetchDrivers(); }, []); - const handleAddDriver = async (driverData: DriverFormData) => { + const handleAddDriver = async (driverData: any) => { try { const token = localStorage.getItem('authToken'); const response = await apiCall('/api/drivers', { @@ -73,18 +78,15 @@ 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: DriverFormData) => { + const handleEditDriver = async (driverData: any) => { try { const token = localStorage.getItem('authToken'); const response = await apiCall(`/api/drivers/${driverData.id}`, { @@ -102,14 +104,11 @@ 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'); } }; @@ -130,39 +129,30 @@ 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 @@ -181,53 +171,16 @@ 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 */} - {filteredDrivers.length === 0 ? ( + {drivers.length === 0 ? (
-

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

-

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

+

No Drivers Found

+

Get started by adding your first driver

) : (
- {filteredDrivers.map((driver) => ( + {drivers.map((driver) => (
{/* Driver Header */} diff --git a/frontend/src/pages/VipDetails.tsx b/frontend/src/pages/VipDetails.tsx index 14ce4aa..e04df14 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 '../utils/api'; +import { apiCall } from '../config/api'; import FlightStatus from '../components/FlightStatus'; import ScheduleManager from '../components/ScheduleManager'; @@ -37,14 +37,15 @@ const VipDetails: React.FC = () => { const fetchVip = async () => { try { const token = localStorage.getItem('authToken'); - const { data: vips } = await apiCall('/api/vips', { + const response = await apiCall('/api/vips', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (vips) { + if (response.ok) { + const vips = await response.json(); const foundVip = vips.find((v: Vip) => v.id === id); if (foundVip) { @@ -73,14 +74,15 @@ const VipDetails: React.FC = () => { if (vip) { try { const token = localStorage.getItem('authToken'); - const { data: scheduleData } = await apiCall(`/api/vips/${vip.id}/schedule`, { + const response = await apiCall(`/api/vips/${vip.id}/schedule`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (scheduleData) { + if (response.ok) { + const scheduleData = await response.json(); setSchedule(scheduleData); } } catch (error) { diff --git a/frontend/src/pages/VipList.tsx b/frontend/src/pages/VipList.tsx index 3b1add0..bd00319 100644 --- a/frontend/src/pages/VipList.tsx +++ b/frontend/src/pages/VipList.tsx @@ -1,11 +1,9 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { apiCall } from '../utils/api'; +import { apiCall } from '../config/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; @@ -28,13 +26,10 @@ 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) => { @@ -55,18 +50,19 @@ const VipList: React.FC = () => { const fetchVips = async () => { try { const token = localStorage.getItem('authToken'); - const { data } = await apiCall('/api/vips', { + const response = await apiCall('/api/vips', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); - if (data) { + if (response.ok) { + const data = await response.json(); const sortedVips = sortVipsByLastName(data); setVips(sortedVips); } else { - console.error('Failed to fetch VIPs'); + console.error('Failed to fetch VIPs:', response.status); } } catch (error) { console.error('Error fetching VIPs:', error); @@ -79,7 +75,6 @@ const VipList: React.FC = () => { }, []); const handleAddVip = async (vipData: any) => { - setSubmitting(true); try { const token = localStorage.getItem('authToken'); const response = await apiCall('/api/vips', { @@ -95,15 +90,11 @@ const VipList: React.FC = () => { const newVip = await response.json(); setVips(prev => sortVipsByLastName([...prev, newVip])); setShowForm(false); - showToast('VIP added successfully!', 'success'); } else { - showToast('Failed to add VIP. Please try again.', 'error'); + console.error('Failed to add VIP:', response.status); } } catch (error) { console.error('Error adding VIP:', error); - showToast('An error occurred while adding the VIP.', 'error'); - } finally { - setSubmitting(false); } }; @@ -123,13 +114,11 @@ 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 { - showToast('Failed to update VIP. Please try again.', 'error'); + console.error('Failed to update VIP:', response.status); } } catch (error) { console.error('Error updating VIP:', error); - showToast('An error occurred while updating the VIP.', 'error'); } }; @@ -150,42 +139,30 @@ const VipList: React.FC = () => { if (response.ok) { setVips(prev => prev.filter(vip => vip.id !== vipId)); - showToast('VIP deleted successfully!', 'success'); } else { - showToast('Failed to delete VIP. Please try again.', 'error'); + console.error('Failed to delete VIP:', response.status); } } 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 @@ -199,52 +176,16 @@ 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 */} - {searchTerm && ( -
-

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

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

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

-

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

+

No VIPs Found

+

Get started by adding your first VIP

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