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:
Matt 2025-08-06 14:31:16 +02:00
parent 4ccccde3e4
commit 024d0da617
26 changed files with 1860 additions and 0 deletions

133
.dockerignore Normal file
View File

@ -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/

25
.env.example Normal file
View File

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

36
.gitignore vendored Normal file
View File

@ -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/

505
DOCKER_DEPLOYMENT_GUIDE.md Normal file
View File

@ -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.

57
Dockerfile Normal file
View File

@ -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"]

View File

@ -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**:

14
app.vue Normal file
View File

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

64
composables/useAuth.ts Normal file
View File

@ -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,
};
};

95
docker-compose.yml Normal file
View File

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

34
docker-entrypoint.sh Normal file
View File

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

17
middleware/auth.ts Normal file
View File

@ -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');
}
});

165
nuxt.config.ts Normal file
View File

@ -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",
},
},
},
},
},
},
});

30
package.json Normal file
View File

@ -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"
}
}

34
pages/auth/callback.vue Normal file
View File

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

12
pages/index.vue Normal file
View File

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

69
pages/login.vue Normal file
View File

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

View File

@ -0,0 +1,6 @@
export default defineNuxtPlugin(async () => {
const { checkAuth } = useAuth();
// Check authentication status on app startup
await checkAuth();
});

View File

@ -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',
});
}
});

View File

@ -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);
});

View File

@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
const sessionManager = createSessionManager();
const destroyCookie = sessionManager.destroySession();
setHeader(event, 'Set-Cookie', destroyCookie);
return { success: true };
});

View File

@ -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 || [],
};
});

83
server/utils/keycloak.ts Normal file
View File

@ -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);
}

81
server/utils/session.ts Normal file
View File

@ -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);
}

3
tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

104
utils/types.ts Normal file
View File

@ -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;
}

175
workflows/deploy.yml Normal file
View File

@ -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 }}"