Initialize Nuxt.js project with Docker deployment setup
- Add core Nuxt.js application structure with TypeScript - Include Docker configuration and deployment guide - Set up project scaffolding with pages, composables, and middleware - Add environment configuration and Git ignore rules
This commit is contained in:
parent
4ccccde3e4
commit
024d0da617
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -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 <your-repo-url>
|
||||
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.
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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**:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Global app setup
|
||||
useHead({
|
||||
titleTemplate: (titleChunk) => {
|
||||
return titleChunk ? `${titleChunk} • MonacoUSA Portal` : 'MonacoUSA Portal';
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { AuthState } from '~/utils/types';
|
||||
|
||||
export const useAuth = () => {
|
||||
const authState = useState<AuthState>('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<AuthState>('/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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-main class="d-flex align-center justify-center min-h-screen">
|
||||
<v-container>
|
||||
<v-row justify="center">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-card class="text-center pa-8">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
class="mb-4"
|
||||
/>
|
||||
<h2 class="text-h5 mb-2">Signing you in...</h2>
|
||||
<p class="text-body-2 text-grey-600">
|
||||
Please wait while we complete your authentication.
|
||||
</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
auth: false,
|
||||
layout: false,
|
||||
});
|
||||
|
||||
// The actual authentication is handled by the server-side callback API
|
||||
// This page just shows a loading state while the redirect happens
|
||||
</script>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Redirect to dashboard if authenticated, otherwise to login -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { authenticated } = useAuth();
|
||||
|
||||
// Redirect based on authentication status
|
||||
await navigateTo(authenticated.value ? '/dashboard' : '/login');
|
||||
</script>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-main class="d-flex align-center justify-center min-h-screen bg-grey-lighten-4">
|
||||
<v-container>
|
||||
<v-row justify="center">
|
||||
<v-col cols="12" sm="8" md="6" lg="4">
|
||||
<v-card class="elevation-8 rounded-lg">
|
||||
<v-card-text class="pa-8">
|
||||
<div class="text-center mb-6">
|
||||
<v-img
|
||||
src="/logo.png"
|
||||
alt="MonacoUSA"
|
||||
max-width="120"
|
||||
class="mx-auto mb-4"
|
||||
/>
|
||||
<h1 class="text-h4 font-weight-bold text-primary mb-2">
|
||||
MonacoUSA Portal
|
||||
</h1>
|
||||
<p class="text-body-1 text-grey-600">
|
||||
Sign in to access your dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
@click="handleLogin"
|
||||
:loading="loading"
|
||||
color="primary"
|
||||
size="large"
|
||||
block
|
||||
class="mb-4"
|
||||
prepend-icon="mdi-login"
|
||||
>
|
||||
Sign In with SSO
|
||||
</v-btn>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-caption text-grey-600">
|
||||
Secure authentication powered by Keycloak
|
||||
</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
auth: false,
|
||||
layout: false,
|
||||
});
|
||||
|
||||
const { login } = useAuth();
|
||||
const loading = ref(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await login();
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default defineNuxtPlugin(async () => {
|
||||
const { checkAuth } = useAuth();
|
||||
|
||||
// Check authentication status on app startup
|
||||
await checkAuth();
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const sessionManager = createSessionManager();
|
||||
const destroyCookie = sessionManager.destroySession();
|
||||
|
||||
setHeader(event, 'Set-Cookie', destroyCookie);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
|
@ -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 || [],
|
||||
};
|
||||
});
|
||||
|
|
@ -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<TokenResponse> {
|
||||
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<UserInfo> {
|
||||
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<TokenResponse> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
|
|
@ -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<T = any> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 }}"
|
||||
Loading…
Reference in New Issue