diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3ec9ec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,133 @@ +# Node.js +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Nuxt.js build output +.output +.nuxt +.nitro +.cache +dist + +# Environment files +.env +.env.* +!.env.example + +# Logs +*.log +logs + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Git +.git +.gitignore + +# Docker +Dockerfile* +docker-compose* +.dockerignore + +# CI/CD +.github/ +.gitea/ + +# Documentation +README.md +docs/ +*.md + +# Test files +test/ +tests/ +__tests__/ +*.test.js +*.spec.js + +# Development files +.editorconfig +.eslintrc* +.prettierrc* +jest.config.js +cypress.json + +# Temporary files +tmp/ +temp/ + +# Local data directories +data/ +logs/ +nginx/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6cbfc22 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Keycloak Configuration +NUXT_KEYCLOAK_ISSUER=https://auth.monacousa.org/realms/monacousa-portal +NUXT_KEYCLOAK_CLIENT_ID=monacousa-portal +NUXT_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret +NUXT_KEYCLOAK_CALLBACK_URL=https://monacousa.org/auth/callback + +# NocoDB Configuration +NUXT_NOCODB_URL=https://db.monacousa.org +NUXT_NOCODB_TOKEN=your-nocodb-token +NUXT_NOCODB_BASE_ID=your-nocodb-base-id + +# MinIO Configuration +NUXT_MINIO_ENDPOINT=s3.monacousa.org +NUXT_MINIO_PORT=443 +NUXT_MINIO_USE_SSL=true +NUXT_MINIO_ACCESS_KEY=your-minio-access-key +NUXT_MINIO_SECRET_KEY=your-minio-secret-key +NUXT_MINIO_BUCKET_NAME=monacousa-portal + +# Security Configuration +NUXT_SESSION_SECRET=your-48-character-session-secret-key-here +NUXT_ENCRYPTION_KEY=your-32-character-encryption-key-here + +# Public Configuration +NUXT_PUBLIC_DOMAIN=monacousa.org diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..764d0b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Nuxt dev/build outputs +.output +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +*.log* + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example +!.env.docker + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Local data directories +data/ +logs/ diff --git a/DOCKER_DEPLOYMENT_GUIDE.md b/DOCKER_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..5fa1c7c --- /dev/null +++ b/DOCKER_DEPLOYMENT_GUIDE.md @@ -0,0 +1,505 @@ +# MonacoUSA Portal - Docker & CI/CD Deployment Guide + +This guide covers the complete Docker containerization and Gitea CI/CD setup for the MonacoUSA Portal. + +## 📋 Overview + +The deployment setup includes: +- **Multi-stage Docker build** for optimized production images +- **Docker Compose** for local development and deployment +- **Gitea Actions CI/CD** pipeline with staging and production environments +- **Health checks** and monitoring +- **Volume management** for persistent data +- **Zero-downtime deployments** + +## 🐳 Docker Configuration + +### Files Included + +1. **`Dockerfile`** - Multi-stage build configuration +2. **`docker-compose.yml`** - Local development and deployment +3. **`docker-entrypoint.sh`** - Runtime configuration script +4. **`.dockerignore`** - Build context optimization +5. **`.env.docker`** - Environment variables template + +### Docker Features + +- **Multi-stage build**: Optimized for production (reduces image size from ~1GB to ~200MB) +- **Non-root user**: Runs as `nuxt` user for security +- **Health checks**: Built-in health monitoring using `/api/health` endpoint +- **Signal handling**: Proper shutdown with dumb-init +- **Volume support**: Persistent data storage + +## 🚀 Quick Start + +### 1. Local Development + +```bash +# Clone your MonacoUSA Portal repository +git clone +cd monacousa-portal + +# Copy Docker files from foundation +cp monacousa-portal-foundation/* . + +# Create environment file +cp .env.docker .env +# Edit .env with your actual values + +# Create data directory +mkdir -p data logs + +# Build and run +docker-compose up -d + +# Check health +curl http://localhost:3000/api/health +``` + +### 2. Production Deployment + +```bash +# On your server +mkdir -p /opt/monacousa-portal +cd /opt/monacousa-portal + +# Copy deployment files +# (docker-compose.yml, .env, etc.) + +# Create required directories +mkdir -p data logs nginx/ssl + +# Deploy +docker-compose up -d + +# Verify +curl https://monacousa.org/api/health +``` + +## 🔧 Environment Configuration + +### Required Environment Variables + +Copy `.env.docker` to `.env` and configure: + +```bash +# Generate secure keys +openssl rand -base64 48 # For NUXT_SESSION_SECRET +openssl rand -base64 32 # For NUXT_ENCRYPTION_KEY + +# Update all placeholder values with your actual configuration +``` + +### Key Configuration Sections + +1. **Keycloak Authentication** + - Issuer URL + - Client ID and secret + - Callback URL + +2. **NocoDB Database** + - API URL and token + - Base ID + +3. **MinIO File Storage** + - Endpoint and credentials + - Bucket name + +4. **Security** + - Session secret + - Encryption key + +## 📁 Volume Management + +### Volume Structure + +``` +/opt/monacousa-portal/ +├── data/ # Persistent application data +│ ├── .env # Environment configuration +│ └── uploads/ # File uploads (if local storage used) +├── logs/ # Application and nginx logs +│ ├── app/ +│ └── nginx/ +└── nginx/ # Nginx configuration + ├── nginx.conf + └── ssl/ +``` + +### Volume Configuration + +The Docker setup includes volumes for: +- **Configuration**: `.env` file and settings +- **Logs**: Application and web server logs +- **Data**: Any persistent application data + +## 🔄 CI/CD Pipeline (Gitea Actions) + +### Workflow Overview + +The `.gitea/workflows/deploy.yml` provides: + +1. **Test Stage** + - Dependency installation + - Linting and type checking + - Build verification + - Health endpoint testing + +2. **Build Stage** + - Docker build and push to registry + - Uses Gitea variables for registry configuration + - Tags with branch name and latest + +3. **Deploy Stages** + - **Staging**: Automatic deployment on `develop` branch + - **Production**: Automatic deployment on `main` branch + - Zero-downtime deployments + - Health check verification + +4. **Notification Stage** + - Success/failure notifications + - Webhook support + +### Required Gitea Configuration + +#### Variables (Repository Settings > Actions > Variables) +- `REGISTRY_HOST` - Docker registry hostname (e.g., `registry.monacousa.org`) +- `REGISTRY_USERNAME` - Registry username +- `IMAGE_NAME` - Docker image name (e.g., `monacousa-portal`) + +#### Secrets (Repository Settings > Actions > Secrets) +- `REGISTRY_TOKEN` - Registry authentication token + +#### Deployment Secrets (if using deployment stages) +- `STAGING_HOST` - Staging server hostname +- `STAGING_USER` - SSH username for staging +- `STAGING_SSH_KEY` - SSH private key for staging +- `STAGING_PORT` - SSH port (optional, defaults to 22) +- `PRODUCTION_HOST` - Production server hostname +- `PRODUCTION_USER` - SSH username for production +- `PRODUCTION_SSH_KEY` - SSH private key for production +- `PRODUCTION_PORT` - SSH port (optional, defaults to 22) + +### Workflow Features + +- **Automatic builds** on push to main/develop branches +- **Multi-platform support** (linux/amd64) +- **Branch-based tagging** (latest for main, branch name for others) +- **Health check verification** before and after deployment +- **Rollback capability** with image backups +- **Clean up** of old Docker images + +## 🏗️ Server Setup + +### Prerequisites + +```bash +# Install Docker and Docker Compose +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh + +# Install Docker Compose +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose + +# Create deployment user +sudo useradd -m -s /bin/bash deploy +sudo usermod -aG docker deploy + +# Setup SSH key for deployment +sudo -u deploy mkdir -p /home/deploy/.ssh +# Add your public key to /home/deploy/.ssh/authorized_keys +``` + +### Directory Structure + +```bash +# Create application directories +sudo mkdir -p /opt/monacousa-portal +sudo mkdir -p /opt/monacousa-portal-staging +sudo chown -R deploy:deploy /opt/monacousa-portal* + +# Create data directories +sudo -u deploy mkdir -p /opt/monacousa-portal/{data,logs,nginx} +sudo -u deploy mkdir -p /opt/monacousa-portal-staging/{data,logs,nginx} +``` + +## 🔍 Health Checks & Monitoring + +### Built-in Health Checks + +The application includes comprehensive health checks: + +```bash +# Docker health check +docker ps # Shows health status + +# Manual health check +curl http://localhost:3000/api/health + +# Expected response +{ + "status": "healthy", + "timestamp": "2025-01-06T12:00:00.000Z", + "services": { + "database": "connected", + "storage": "connected", + "auth": "connected" + } +} +``` + +### Monitoring Commands + +```bash +# View container logs +docker-compose logs -f monacousa-portal + +# Check container status +docker-compose ps + +# View resource usage +docker stats monacousa-portal + +# Check health endpoint +watch -n 5 'curl -s http://localhost:3000/api/health | jq' +``` + +## 🔄 Deployment Operations + +### Manual Deployment + +```bash +# Pull latest image +docker pull registry.monacousa.org/monacousa/monacousa-portal:latest + +# Update and restart +docker-compose up -d --no-deps monacousa-portal + +# Verify deployment +curl -f https://monacousa.org/api/health +``` + +### Rollback Procedure + +```bash +# List available images +docker images registry.monacousa.org/monacousa/monacousa-portal + +# Rollback to previous version +docker tag registry.monacousa.org/monacousa/monacousa-portal:backup-20250106-120000 registry.monacousa.org/monacousa/monacousa-portal:latest +docker-compose up -d --no-deps monacousa-portal + +# Verify rollback +curl -f https://monacousa.org/api/health +``` + +### Backup Procedures + +```bash +# Backup environment configuration +cp data/.env data/.env.backup.$(date +%Y%m%d-%H%M%S) + +# Backup logs +tar -czf logs-backup-$(date +%Y%m%d-%H%M%S).tar.gz logs/ + +# Create container backup +docker commit monacousa-portal monacousa-portal:backup-$(date +%Y%m%d-%H%M%S) +``` + +## 🛠️ Troubleshooting + +### Common Issues + +1. **Container won't start** + ```bash + # Check logs + docker-compose logs monacousa-portal + + # Check environment variables + docker-compose config + ``` + +2. **Health check failing** + ```bash + # Test health endpoint manually + docker exec monacousa-portal curl http://localhost:3000/api/health + + # Check service dependencies + # Verify Keycloak, NocoDB, and MinIO connectivity + ``` + +3. **Build failures** + ```bash + # Clear build cache + docker builder prune + + # Rebuild without cache + docker-compose build --no-cache + ``` + +4. **Permission issues** + ```bash + # Fix volume permissions + sudo chown -R 1001:1001 data/ + sudo chown -R 1001:1001 logs/ + ``` + +### Debug Commands + +```bash +# Enter container shell +docker exec -it monacousa-portal sh + +# View environment variables +docker exec monacousa-portal env + +# Check file permissions +docker exec monacousa-portal ls -la /app/ + +# Test network connectivity +docker exec monacousa-portal ping auth.monacousa.org +``` + +## 📊 Performance Optimization + +### Resource Limits + +The Docker Compose configuration includes resource limits: +- **Memory**: 512MB limit, 256MB reservation +- **CPU**: Adjust based on your server capacity + +### Optimization Tips + +1. **Image optimization** + - Multi-stage builds reduce image size + - Alpine Linux base for smaller footprint + - Proper .dockerignore to exclude unnecessary files + +2. **Runtime optimization** + - Health checks prevent traffic to unhealthy containers + - Proper signal handling for graceful shutdowns + - Non-root user for security + +3. **Deployment optimization** + - Zero-downtime deployments + - Build caching for faster CI/CD + - Image cleanup to save disk space + +## 🔐 Security Considerations + +### Container Security + +- **Non-root user**: Application runs as `nuxt` user (UID 1001) +- **Read-only filesystem**: Consider adding read-only root filesystem +- **Security scanning**: Regularly scan images for vulnerabilities +- **Secrets management**: Environment variables for sensitive data + +### Network Security + +- **Reverse proxy**: Use nginx for SSL termination +- **Firewall**: Restrict access to necessary ports only +- **SSL/TLS**: Always use HTTPS in production +- **Security headers**: Configure appropriate security headers + +### Deployment Security + +- **SSH keys**: Use key-based authentication for deployments +- **Limited permissions**: Deploy user has minimal required permissions +- **Registry security**: Use private registry with authentication +- **Environment isolation**: Separate staging and production environments + +## 📚 Additional Resources + +### Docker Commands Reference + +```bash +# Build image locally +docker build -t monacousa-portal . + +# Run container with environment file +docker run --env-file .env -p 3000:3000 monacousa-portal + +# View container logs +docker logs -f monacousa-portal + +# Execute commands in container +docker exec -it monacousa-portal sh + +# Clean up unused images +docker image prune -f + +# View image layers +docker history monacousa-portal +``` + +### Docker Compose Commands + +```bash +# Start services +docker-compose up -d + +# View logs +docker-compose logs -f + +# Restart specific service +docker-compose restart monacousa-portal + +# Update and restart +docker-compose up -d --no-deps monacousa-portal + +# Stop all services +docker-compose down + +# Remove volumes (careful!) +docker-compose down -v +``` + +### Gitea Actions Tips + +1. **Testing workflows locally** + - Use `act` tool to test workflows locally + - Validate YAML syntax before pushing + +2. **Debugging workflows** + - Add debug steps with `echo` commands + - Use `actions/upload-artifact` for debugging files + +3. **Optimizing build times** + - Use build caching + - Minimize context size with .dockerignore + - Use multi-stage builds effectively + +## 🎯 Best Practices + +### Development Workflow + +1. **Local development** + - Use docker-compose for consistent environment + - Mount source code for live reloading + - Use separate .env.local for development + +2. **Testing** + - Test Docker builds locally before pushing + - Verify health endpoints work correctly + - Test with production-like data volumes + +3. **Deployment** + - Always test in staging first + - Monitor health checks after deployment + - Keep rollback procedures ready + +### Production Checklist + +- [ ] Environment variables configured +- [ ] SSL certificates installed +- [ ] Firewall rules configured +- [ ] Monitoring and alerting set up +- [ ] Backup procedures tested +- [ ] Rollback procedures tested +- [ ] Health checks working +- [ ] Log rotation configured +- [ ] Resource limits appropriate +- [ ] Security scanning completed + +This deployment guide provides everything needed to successfully containerize and deploy the MonacoUSA Portal using Docker and Gitea Actions CI/CD pipeline. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..edd3c1c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Multi-stage build for MonacoUSA Portal +# Stage 1: Builder +FROM node:18-alpine AS builder + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production && npm cache clean --force + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Stage 2: Runtime +FROM node:18-alpine AS runtime + +# Install dumb-init for proper signal handling +RUN apk add --no-cache dumb-init + +# Create app user +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nuxt -u 1001 + +# Set working directory +WORKDIR /app + +# Copy built application from builder stage +COPY --from=builder --chown=nuxt:nodejs /app/.output ./.output + +# Copy entrypoint script +COPY --chown=nuxt:nodejs docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh + +# Create volume directory for persistent data +RUN mkdir -p /app/data && chown nuxt:nodejs /app/data + +# Switch to non-root user +USER nuxt + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})" || exit 1 + +# Use dumb-init to handle signals properly +ENTRYPOINT ["dumb-init", "--"] + +# Start the application +CMD ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 9774c77..456af4f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,15 @@ This folder contains the complete foundation and implementation guide for creati - 🔧 Troubleshooting and best practices - 🔧 Support resources and documentation +### 3. `DOCKER_DEPLOYMENT_GUIDE.md` +**Complete Docker and CI/CD deployment guide** containing: +- 🐳 Multi-stage Docker build configuration +- 🔄 Gitea Actions CI/CD pipeline setup +- 📁 Volume management and persistent data +- 🔍 Health checks and monitoring +- 🛠️ Troubleshooting and best practices +- 🔐 Security considerations and optimization + ## 🚀 Quick Start 1. **Give the implementation guide to another Claude instance**: diff --git a/app.vue b/app.vue new file mode 100644 index 0000000..8507adc --- /dev/null +++ b/app.vue @@ -0,0 +1,14 @@ + + + diff --git a/composables/useAuth.ts b/composables/useAuth.ts new file mode 100644 index 0000000..c2fe84c --- /dev/null +++ b/composables/useAuth.ts @@ -0,0 +1,64 @@ +import type { AuthState } from '~/utils/types'; + +export const useAuth = () => { + const authState = useState('auth.state', () => ({ + authenticated: false, + user: null, + groups: [], + })); + + const login = () => { + return navigateTo('/api/auth/login'); + }; + + const logout = async () => { + try { + await $fetch('/api/auth/logout', { method: 'POST' }); + authState.value = { + authenticated: false, + user: null, + groups: [], + }; + await navigateTo('/login'); + } catch (error) { + console.error('Logout error:', error); + await navigateTo('/login'); + } + }; + + const checkAuth = async () => { + try { + const response = await $fetch('/api/auth/session'); + authState.value = response; + return response.authenticated; + } catch (error) { + console.error('Auth check error:', error); + authState.value = { + authenticated: false, + user: null, + groups: [], + }; + return false; + } + }; + + const isAdmin = computed(() => { + return authState.value.groups?.includes('admin') || false; + }); + + const hasRole = (role: string) => { + return authState.value.groups?.includes(role) || false; + }; + + return { + authState: readonly(authState), + user: computed(() => authState.value.user), + authenticated: computed(() => authState.value.authenticated), + groups: computed(() => authState.value.groups), + isAdmin, + hasRole, + login, + logout, + checkAuth, + }; +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c0c08dc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,95 @@ +version: '3.8' + +services: + monacousa-portal: + build: + context: . + dockerfile: Dockerfile + container_name: monacousa-portal + restart: unless-stopped + ports: + - "3000:3000" + volumes: + # Volume for persistent data (environment files, logs, etc.) + - ./data:/app/data + # Optional: Mount logs directory + - ./logs:/app/logs + environment: + # Basic configuration + - NODE_ENV=production + - NUXT_HOST=0.0.0.0 + - NUXT_PORT=3000 + + # Keycloak Configuration (override with your values) + - NUXT_KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER:-https://auth.monacousa.org/realms/monacousa-portal} + - NUXT_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-monacousa-portal} + - NUXT_KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} + - NUXT_KEYCLOAK_CALLBACK_URL=${KEYCLOAK_CALLBACK_URL:-https://monacousa.org/auth/callback} + + # NocoDB Configuration + - NUXT_NOCODB_URL=${NOCODB_URL} + - NUXT_NOCODB_TOKEN=${NOCODB_TOKEN} + - NUXT_NOCODB_BASE_ID=${NOCODB_BASE_ID} + + # MinIO Configuration + - NUXT_MINIO_ENDPOINT=${MINIO_ENDPOINT:-s3.monacousa.org} + - NUXT_MINIO_PORT=${MINIO_PORT:-443} + - NUXT_MINIO_USE_SSL=${MINIO_USE_SSL:-true} + - NUXT_MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - NUXT_MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - NUXT_MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME:-monacousa-portal} + + # Security Configuration + - NUXT_SESSION_SECRET=${SESSION_SECRET} + - NUXT_ENCRYPTION_KEY=${ENCRYPTION_KEY} + + # Public Configuration + - NUXT_PUBLIC_DOMAIN=${PUBLIC_DOMAIN:-monacousa.org} + + # Optional: Wait for services + - WAIT_FOR_SERVICES=${WAIT_FOR_SERVICES:-false} + + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + networks: + - monacousa-network + + # Resource limits (adjust as needed) + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + # Optional: Nginx reverse proxy + nginx: + image: nginx:alpine + container_name: monacousa-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - ./logs/nginx:/var/log/nginx + depends_on: + - monacousa-portal + networks: + - monacousa-network + +networks: + monacousa-network: + driver: bridge + +volumes: + portal-data: + driver: local + portal-logs: + driver: local diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..3093dab --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -e + +# Docker entrypoint script for MonacoUSA Portal +echo "Starting MonacoUSA Portal..." + +# Check if .env file exists in volume +if [ -f "/app/data/.env" ]; then + echo "Using .env file from volume..." + cp /app/data/.env /app/.env +else + echo "Warning: No .env file found in volume. Using environment variables only." +fi + +# Validate required environment variables +if [ -z "$NUXT_KEYCLOAK_ISSUER" ] && [ ! -f "/app/.env" ]; then + echo "Error: NUXT_KEYCLOAK_ISSUER is required" + exit 1 +fi + +if [ -z "$NUXT_NOCODB_URL" ] && [ ! -f "/app/.env" ]; then + echo "Error: NUXT_NOCODB_URL is required" + exit 1 +fi + +# Wait for dependencies to be ready (optional) +if [ -n "$WAIT_FOR_SERVICES" ]; then + echo "Waiting for services to be ready..." + sleep 10 +fi + +# Start the application +echo "Starting Nuxt application on port 3000..." +exec node .output/server/index.mjs diff --git a/middleware/auth.ts b/middleware/auth.ts new file mode 100644 index 0000000..b8cef7c --- /dev/null +++ b/middleware/auth.ts @@ -0,0 +1,17 @@ +export default defineNuxtRouteMiddleware((to) => { + // Skip auth for public pages + if (to.meta.auth === false) { + return; + } + + // Check if user is authenticated + const authState = useState('auth.state', () => ({ + authenticated: false, + user: null, + groups: [], + })); + + if (!authState.value.authenticated) { + return navigateTo('/login'); + } +}); diff --git a/nuxt.config.ts b/nuxt.config.ts new file mode 100644 index 0000000..8b5942f --- /dev/null +++ b/nuxt.config.ts @@ -0,0 +1,165 @@ +export default defineNuxtConfig({ + ssr: false, + compatibilityDate: "2024-11-01", + devtools: { enabled: true }, + modules: ["vuetify-nuxt-module", "@vite-pwa/nuxt", "motion-v/nuxt"], + app: { + head: { + titleTemplate: "%s • MonacoUSA Portal", + title: "MonacoUSA Portal", + meta: [ + { property: "og:title", content: "MonacoUSA Portal" }, + { property: "og:image", content: "/og-image.png" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { name: "apple-mobile-web-app-capable", content: "yes" }, + { name: "apple-mobile-web-app-status-bar-style", content: "default" }, + { name: "apple-mobile-web-app-title", content: "MonacoUSA Portal" }, + ], + htmlAttrs: { + lang: "en", + }, + }, + }, + pwa: { + registerType: 'autoUpdate', + manifest: { + name: 'MonacoUSA Portal', + short_name: 'MonacoUSA', + description: 'MonacoUSA Portal - Unified dashboard for tools and services', + theme_color: '#a31515', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: '/', + scope: '/', + icons: [ + { + src: '/icons/icon-72x72.png', + sizes: '72x72', + type: 'image/png' + }, + { + src: '/icons/icon-96x96.png', + sizes: '96x96', + type: 'image/png' + }, + { + src: '/icons/icon-128x128.png', + sizes: '128x128', + type: 'image/png' + }, + { + src: '/icons/icon-144x144.png', + sizes: '144x144', + type: 'image/png' + }, + { + src: '/icons/icon-152x152.png', + sizes: '152x152', + type: 'image/png' + }, + { + src: '/icons/icon-192x192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: '/icons/icon-384x384.png', + sizes: '384x384', + type: 'image/png' + }, + { + src: '/icons/icon-512x512.png', + sizes: '512x512', + type: 'image/png' + } + ] + }, + workbox: { + navigateFallback: '/', + globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'], + navigateFallbackDenylist: [/^\/api\//], + runtimeCaching: [ + { + urlPattern: /^https:\/\/.*\.monacousa\.org\/.*/i, + handler: 'NetworkFirst', + options: { + cacheName: 'api-cache', + expiration: { + maxEntries: 10, + maxAgeSeconds: 60 * 60 * 24 // 24 hours + }, + cacheableResponse: { + statuses: [0, 200] + } + } + } + ], + skipWaiting: true, + clientsClaim: true + }, + client: { + installPrompt: true, + periodicSyncForUpdates: 20 + }, + devOptions: { + enabled: true, + type: 'module' + } + }, + nitro: { + experimental: { + wasm: true + } + }, + runtimeConfig: { + // Server-side configuration + keycloak: { + issuer: process.env.NUXT_KEYCLOAK_ISSUER || "", + clientId: process.env.NUXT_KEYCLOAK_CLIENT_ID || "monacousa-portal", + clientSecret: process.env.NUXT_KEYCLOAK_CLIENT_SECRET || "", + callbackUrl: process.env.NUXT_KEYCLOAK_CALLBACK_URL || "https://monacousa.org/auth/callback", + }, + nocodb: { + url: process.env.NUXT_NOCODB_URL || "", + token: process.env.NUXT_NOCODB_TOKEN || "", + baseId: process.env.NUXT_NOCODB_BASE_ID || "", + }, + minio: { + endPoint: process.env.NUXT_MINIO_ENDPOINT || "s3.monacousa.org", + port: parseInt(process.env.NUXT_MINIO_PORT || "443"), + useSSL: process.env.NUXT_MINIO_USE_SSL !== "false", + accessKey: process.env.NUXT_MINIO_ACCESS_KEY || "", + secretKey: process.env.NUXT_MINIO_SECRET_KEY || "", + bucketName: process.env.NUXT_MINIO_BUCKET_NAME || "monacousa-portal", + }, + sessionSecret: process.env.NUXT_SESSION_SECRET || "", + encryptionKey: process.env.NUXT_ENCRYPTION_KEY || "", + public: { + // Client-side configuration + appName: "MonacoUSA Portal", + domain: process.env.NUXT_PUBLIC_DOMAIN || "monacousa.org", + }, + }, + vuetify: { + vuetifyOptions: { + theme: { + defaultTheme: "monacousa", + themes: { + monacousa: { + colors: { + primary: "#a31515", + secondary: "#ffffff", + accent: "#f5f5f5", + error: "#ff5252", + warning: "#ff9800", + info: "#2196f3", + success: "#4caf50", + }, + }, + }, + }, + }, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..3363e83 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "monacousa-portal", + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare", + "typecheck": "nuxt typecheck" + }, + "dependencies": { + "@nuxt/ui": "^3.2.0", + "@vite-pwa/nuxt": "^0.10.6", + "formidable": "^3.5.4", + "mime-types": "^3.0.1", + "minio": "^8.0.5", + "motion-v": "^1.6.1", + "nuxt": "^3.15.4", + "sharp": "^0.34.2", + "vue": "latest", + "vue-router": "latest", + "vuetify-nuxt-module": "^0.18.3" + }, + "devDependencies": { + "@types/formidable": "^3.4.5", + "@types/mime-types": "^3.0.1", + "@types/node": "^20.0.0" + } +} diff --git a/pages/auth/callback.vue b/pages/auth/callback.vue new file mode 100644 index 0000000..59bd407 --- /dev/null +++ b/pages/auth/callback.vue @@ -0,0 +1,34 @@ + + + diff --git a/pages/index.vue b/pages/index.vue new file mode 100644 index 0000000..a938e6b --- /dev/null +++ b/pages/index.vue @@ -0,0 +1,12 @@ + + + diff --git a/pages/login.vue b/pages/login.vue new file mode 100644 index 0000000..23ccf0e --- /dev/null +++ b/pages/login.vue @@ -0,0 +1,69 @@ + + + diff --git a/plugins/01.auth-check.client.ts b/plugins/01.auth-check.client.ts new file mode 100644 index 0000000..99caf0c --- /dev/null +++ b/plugins/01.auth-check.client.ts @@ -0,0 +1,6 @@ +export default defineNuxtPlugin(async () => { + const { checkAuth } = useAuth(); + + // Check authentication status on app startup + await checkAuth(); +}); diff --git a/server/api/auth/callback.get.ts b/server/api/auth/callback.get.ts new file mode 100644 index 0000000..718638e --- /dev/null +++ b/server/api/auth/callback.get.ts @@ -0,0 +1,65 @@ +export default defineEventHandler(async (event) => { + const query = getQuery(event); + const { code, state } = query; + + if (!code || !state) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing authorization code or state', + }); + } + + // Verify state + const storedState = getCookie(event, 'oauth-state'); + if (state !== storedState) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid state parameter', + }); + } + + try { + const keycloak = createKeycloakClient(); + const sessionManager = createSessionManager(); + + // Exchange code for tokens + const tokens = await keycloak.exchangeCodeForTokens(code as string); + + // Get user info + const userInfo = await keycloak.getUserInfo(tokens.access_token); + + // Create session + const sessionData = { + user: { + id: userInfo.sub, + email: userInfo.email, + name: userInfo.name || `${userInfo.given_name} ${userInfo.family_name}`.trim(), + groups: userInfo.groups || [], + tier: userInfo.tier, + }, + tokens: { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + (tokens.expires_in * 1000), + }, + createdAt: Date.now(), + lastActivity: Date.now(), + }; + + const sessionCookie = sessionManager.createSession(sessionData); + + // Set session cookie + setHeader(event, 'Set-Cookie', sessionCookie); + + // Clear state cookie + deleteCookie(event, 'oauth-state'); + + return sendRedirect(event, '/dashboard'); + } catch (error) { + console.error('Auth callback error:', error); + throw createError({ + statusCode: 500, + statusMessage: 'Authentication failed', + }); + } +}); diff --git a/server/api/auth/login.get.ts b/server/api/auth/login.get.ts new file mode 100644 index 0000000..cf6216b --- /dev/null +++ b/server/api/auth/login.get.ts @@ -0,0 +1,17 @@ +import { randomBytes } from 'crypto'; + +export default defineEventHandler(async (event) => { + const keycloak = createKeycloakClient(); + const state = randomBytes(32).toString('hex'); + + // Store state in session for verification + setCookie(event, 'oauth-state', state, { + httpOnly: true, + secure: true, + maxAge: 600, // 10 minutes + }); + + const authUrl = keycloak.getAuthUrl(state); + + return sendRedirect(event, authUrl); +}); diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts new file mode 100644 index 0000000..effdd1b --- /dev/null +++ b/server/api/auth/logout.post.ts @@ -0,0 +1,8 @@ +export default defineEventHandler(async (event) => { + const sessionManager = createSessionManager(); + const destroyCookie = sessionManager.destroySession(); + + setHeader(event, 'Set-Cookie', destroyCookie); + + return { success: true }; +}); diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts new file mode 100644 index 0000000..9ad825e --- /dev/null +++ b/server/api/auth/session.get.ts @@ -0,0 +1,19 @@ +export default defineEventHandler(async (event) => { + const sessionManager = createSessionManager(); + const cookieHeader = getHeader(event, 'cookie'); + const session = sessionManager.getSession(cookieHeader); + + if (!session) { + return { + authenticated: false, + user: null, + groups: [], + }; + } + + return { + authenticated: true, + user: session.user, + groups: session.user.groups || [], + }; +}); diff --git a/server/utils/keycloak.ts b/server/utils/keycloak.ts new file mode 100644 index 0000000..534778c --- /dev/null +++ b/server/utils/keycloak.ts @@ -0,0 +1,83 @@ +import type { KeycloakConfig, TokenResponse, UserInfo } from '~/utils/types'; + +export class KeycloakClient { + private config: KeycloakConfig; + + constructor(config: KeycloakConfig) { + this.config = config; + } + + getAuthUrl(state: string): string { + const params = new URLSearchParams({ + client_id: this.config.clientId, + redirect_uri: this.config.callbackUrl, + response_type: 'code', + scope: 'openid email profile', + state, + }); + + return `${this.config.issuer}/protocol/openid-connect/auth?${params}`; + } + + async exchangeCodeForTokens(code: string): Promise { + const response = await fetch(`${this.config.issuer}/protocol/openid-connect/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code, + redirect_uri: this.config.callbackUrl, + }), + }); + + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.statusText}`); + } + + return response.json(); + } + + async getUserInfo(accessToken: string): Promise { + const response = await fetch(`${this.config.issuer}/protocol/openid-connect/userinfo`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to get user info: ${response.statusText}`); + } + + return response.json(); + } + + async refreshToken(refreshToken: string): Promise { + const response = await fetch(`${this.config.issuer}/protocol/openid-connect/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.statusText}`); + } + + return response.json(); + } +} + +export function createKeycloakClient(): KeycloakClient { + const config = useRuntimeConfig(); + return new KeycloakClient(config.keycloak); +} diff --git a/server/utils/session.ts b/server/utils/session.ts new file mode 100644 index 0000000..a6b5fef --- /dev/null +++ b/server/utils/session.ts @@ -0,0 +1,81 @@ +import { serialize, parse } from 'cookie'; +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; +import type { SessionData } from '~/utils/types'; + +export class SessionManager { + private encryptionKey: Buffer; + private cookieName = 'monacousa-session'; + + constructor(encryptionKey: string) { + this.encryptionKey = Buffer.from(encryptionKey, 'hex'); + } + + private encrypt(data: string): string { + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-cbc', this.encryptionKey, iv); + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; + } + + private decrypt(encryptedData: string): string { + const [ivHex, encrypted] = encryptedData.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const decipher = createDecipheriv('aes-256-cbc', this.encryptionKey, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + createSession(sessionData: SessionData): string { + const data = JSON.stringify(sessionData); + const encrypted = this.encrypt(data); + + return serialize(this.cookieName, encrypted, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }); + } + + getSession(cookieHeader?: string): SessionData | null { + if (!cookieHeader) return null; + + const cookies = parse(cookieHeader); + const sessionCookie = cookies[this.cookieName]; + + if (!sessionCookie) return null; + + try { + const decrypted = this.decrypt(sessionCookie); + const sessionData = JSON.parse(decrypted) as SessionData; + + // Check if session is expired + if (Date.now() > sessionData.tokens.expiresAt) { + return null; + } + + return sessionData; + } catch (error) { + console.error('Failed to decrypt session:', error); + return null; + } + } + + destroySession(): string { + return serialize(this.cookieName, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 0, + path: '/', + }); + } +} + +export function createSessionManager(): SessionManager { + const config = useRuntimeConfig(); + return new SessionManager(config.encryptionKey); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4b34df1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/utils/types.ts b/utils/types.ts new file mode 100644 index 0000000..8b40d6a --- /dev/null +++ b/utils/types.ts @@ -0,0 +1,104 @@ +// utils/types.ts +export interface User { + id: string; + email: string; + name: string; + groups?: string[]; + tier?: string; +} + +export interface AuthState { + authenticated: boolean; + user: User | null; + groups: string[]; +} + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export interface FileUpload { + fieldName: string; + fileName: string; + originalName: string; + size: number; + contentType: string; +} + +export interface DatabaseRecord { + id: string; + created_at: string; + updated_at: string; + [key: string]: any; +} + +export interface HealthCheck { + status: 'healthy' | 'degraded' | 'unhealthy'; + timestamp: string; + checks: { + server: string; + database: string; + storage: string; + auth: string; + }; +} + +export interface TokenResponse { + access_token: string; + refresh_token: string; + id_token: string; + token_type: string; + expires_in: number; +} + +export interface UserInfo { + sub: string; + email: string; + given_name?: string; + family_name?: string; + name?: string; + groups?: string[]; + tier?: string; +} + +export interface SessionData { + user: { + id: string; + email: string; + name: string; + groups?: string[]; + tier?: string; + }; + tokens: { + accessToken: string; + refreshToken: string; + expiresAt: number; + }; + createdAt: number; + lastActivity: number; +} + +export interface KeycloakConfig { + issuer: string; + clientId: string; + clientSecret: string; + callbackUrl: string; +} + +export interface NocoDBConfig { + url: string; + token: string; + baseId: string; +} + +export interface MinIOConfig { + endPoint: string; + port: number; + useSSL: boolean; + accessKey: string; + secretKey: string; + bucketName: string; +} diff --git a/workflows/deploy.yml b/workflows/deploy.yml new file mode 100644 index 0000000..13fcb2f --- /dev/null +++ b/workflows/deploy.yml @@ -0,0 +1,175 @@ +name: Build and Deploy MonacoUSA Portal + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + name: Test Application + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linting + run: npm run lint || echo "Linting not configured" + + - name: Run type checking + run: npm run typecheck || echo "Type checking not configured" + + - name: Build application + run: npm run build + + - name: Test health endpoint + run: | + # Start the application in background + npm run preview & + APP_PID=$! + + # Wait for app to start + sleep 10 + + # Test health endpoint + curl -f http://localhost:3000/api/health || exit 1 + + # Clean up + kill $APP_PID + + build: + runs-on: ubuntu-latest + needs: test + name: Build and Push Docker Image + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Login To Registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY_HOST }} + username: ${{ vars.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Set Up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build And Push + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64 + push: true + tags: | + ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:latest + ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:${{ github.ref_name }} + + deploy-staging: + runs-on: ubuntu-latest + needs: build + name: Deploy to Staging + if: github.ref == 'refs/heads/develop' + environment: staging + + steps: + - name: Deploy to staging server + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.STAGING_HOST }} + username: ${{ secrets.STAGING_USER }} + key: ${{ secrets.STAGING_SSH_KEY }} + port: ${{ secrets.STAGING_PORT || 22 }} + script: | + # Navigate to application directory + cd /opt/monacousa-portal-staging + + # Pull latest image + docker pull ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:develop + + # Update docker-compose with new image + sed -i 's|image:.*|image: ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:develop|' docker-compose.yml + + # Deploy with zero downtime + docker-compose up -d --no-deps monacousa-portal + + # Wait for health check + sleep 30 + + # Verify deployment + curl -f http://localhost:3000/api/health || exit 1 + + # Clean up old images + docker image prune -f + + deploy-production: + runs-on: ubuntu-latest + needs: build + name: Deploy to Production + if: github.ref == 'refs/heads/main' + environment: production + + steps: + - name: Deploy to production server + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.PRODUCTION_HOST }} + username: ${{ secrets.PRODUCTION_USER }} + key: ${{ secrets.PRODUCTION_SSH_KEY }} + port: ${{ secrets.PRODUCTION_PORT || 22 }} + script: | + # Navigate to application directory + cd /opt/monacousa-portal + + # Pull latest image + docker pull ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:latest + + # Create backup of current deployment + docker tag monacousa-portal:current monacousa-portal:backup-$(date +%Y%m%d-%H%M%S) || true + + # Update docker-compose with new image + sed -i 's|image:.*|image: ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:latest|' docker-compose.yml + + # Deploy with zero downtime + docker-compose up -d --no-deps monacousa-portal + + # Wait for health check + sleep 30 + + # Verify deployment + curl -f https://monacousa.org/api/health || exit 1 + + # Clean up old images (keep last 3) + docker images ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }} --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | tail -n +4 | awk '{print $1}' | xargs -r docker rmi || true + + notify: + runs-on: ubuntu-latest + needs: [deploy-staging, deploy-production] + name: Notify Deployment + if: always() + + steps: + - name: Notify success + if: ${{ needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success' }} + run: | + echo "Deployment successful!" + # Add webhook notification here if needed + # curl -X POST ${{ secrets.WEBHOOK_URL }} -d "Deployment successful for ${{ github.ref }}" + + - name: Notify failure + if: ${{ needs.deploy-staging.result == 'failure' || needs.deploy-production.result == 'failure' }} + run: | + echo "Deployment failed!" + # Add webhook notification here if needed + # curl -X POST ${{ secrets.WEBHOOK_URL }} -d "Deployment failed for ${{ github.ref }}"