feat: migrar a useAuthentik y configurar CI/CD con Gitea Actions
Some checks failed
build-and-deploy / build (push) Failing after 6s
build-and-deploy / deploy (push) Has been skipped
deploy-analiticaNucleo / deploy (push) Failing after 2s

- Migrar de useAuth() a useAuthentik() para autenticación SSR
- Actualizar componentes UserMenu, AppSidebar y profile.vue
- Configurar docker-compose.yml con variables dinámicas
- Agregar Gitea Actions workflow para build y deploy automático
- Implementar hook de monitoreo de Gitea Actions
- Configurar secrets y variables para deploy seguro
- Actualizar configuración de Traefik con Authentik Forward Auth
This commit is contained in:
2025-10-13 11:25:40 -06:00
parent 052d73920b
commit d32b3e8db3
13 changed files with 934 additions and 124 deletions

152
.claude/hooks/README.md Normal file
View File

@@ -0,0 +1,152 @@
# Gitea Actions Monitor Hook
Este hook monitorea automáticamente tus Gitea Actions después de hacer `git push` y te notifica cuando terminan.
## Configuración
### 1. Crear un token de acceso en Gitea
1. Ve a: https://gitea.nucleoriofrio.com/user/settings/applications
2. En la sección **"Generate New Token"**:
- **Token Name**: `claude-code-monitor` (o el nombre que prefieras)
- **Select Permissions**: Marca `read:repository` o `repo` (lectura de repositorio)
3. Click en **"Generate Token"**
4. **IMPORTANTE**: Copia el token inmediatamente (solo se muestra una vez)
### 2. Configurar la variable de entorno
Agrega el token a tu archivo de configuración de shell:
**Para Bash** (`~/.bashrc`):
```bash
export GITEA_TOKEN='tu_token_aqui'
```
**Para Zsh** (`~/.zshrc`):
```bash
export GITEA_TOKEN='tu_token_aqui'
```
**Para Fish** (`~/.config/fish/config.fish`):
```fish
set -x GITEA_TOKEN 'tu_token_aqui'
```
Luego recarga la configuración:
```bash
source ~/.bashrc # o ~/.zshrc o reinicia la terminal
```
### 3. Verificar que funciona
Verifica que la variable está configurada:
```bash
echo $GITEA_TOKEN
```
Deberías ver tu token.
## Uso
Una vez configurado, el hook se activa automáticamente cuando Claude Code ejecuta `git push`:
1. Claude ejecuta `git push`
2. El hook verifica que Actions estén habilitadas
3. El hook se activa automáticamente y comienza a monitorear
4. **Claude se congela** mientras espera a que termine la Gitea Action (máximo 10 minutos)
5. Puedes presionar **Ctrl+C** para interrumpir la espera si es necesario
6. Cuando termine, Claude te muestra información detallada:
**Ejemplo de action exitosa:**
```
✅ Gitea Action completada: EXITOSO
📋 Detalles:
• Workflow: build-and-deploy (build-and-deploy.yml)
• Run #3
• Evento: push
• Branch: master
• Commit: a1b2c3d4
• Título: Update README: Add Claude Code hooks feature
• Duración: 5m 23s
• Iniciado: 2025-10-12T14:30:05Z
• Finalizado: 2025-10-12T14:35:28Z
🔗 Ver logs completos:
https://gitea.nucleoriofrio.com/nucleo000/plantillaNuxtAuthentikProxy/actions/runs/123
```
**Si las Actions no están habilitadas:**
```
⚠️ Git push exitoso, pero las Gitea Actions NO están habilitadas en este repositorio.
📝 Para habilitar Actions:
1. Ve a: https://gitea.nucleoriofrio.com/nucleo000/plantillaNuxtAuthentikProxy/settings
2. Busca la sección 'Actions' o 'Workflows'
3. Activa las Actions
```
## Configuración Avanzada
### Cambiar el timeout
Edita `.claude/settings.local.json` y modifica el valor `timeout` (en segundos):
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/monitor-gitea-action.sh",
"timeout": 300 // 5 minutos en lugar de 10
}
]
}
]
}
}
```
### Cambiar el intervalo de polling
Edita `.claude/hooks/monitor-gitea-action.sh` y modifica:
```bash
POLL_INTERVAL=10 # Consultar cada 10 segundos (puedes cambiar a 5, 15, 30, etc.)
```
## Troubleshooting
### El hook no se activa
1. Verifica que el archivo de configuración es correcto: `cat .claude/settings.local.json`
2. Reinicia Claude Code para que recargue la configuración
3. Revisa los logs con `claude --debug`
### Error "falta GITEA_TOKEN"
El token no está configurado. Sigue los pasos de configuración arriba.
### Timeout: La action todavía está corriendo
La action tardó más de 10 minutos. Puedes:
- Aumentar el `timeout` en la configuración
- Verificar el estado manualmente en Gitea
- Optimizar tu Gitea Action para que sea más rápida
### El script no puede conectarse a la API
1. Verifica que puedes acceder a Gitea: `curl https://gitea.nucleoriofrio.com`
2. Verifica que el token es válido:
```bash
curl -H "Authorization: token $GITEA_TOKEN" \
https://gitea.nucleoriofrio.com/api/v1/repos/nucleo000/plantillaNuxtAuthentikProxy/actions/tasks?limit=1
```
## Desactivar el hook
Si quieres desactivar temporalmente el hook, comenta o elimina la sección `hooks` en `.claude/settings.local.json`.

View File

@@ -0,0 +1,197 @@
#!/bin/bash
# Monitor Gitea Action after git push
# Este script se ejecuta después de un git push y espera a que termine la Gitea Action
set -euo pipefail
# Configuración
GITEA_URL="https://gitea.nucleoriofrio.com"
OWNER="nucleo000"
REPO="analiticaNucleo"
# Intentar cargar el token desde el entorno o desde ~/.bashrc
GITEA_TOKEN="${GITEA_TOKEN:-}"
if [ -z "$GITEA_TOKEN" ] && [ -f "$HOME/.bashrc" ]; then
# Intentar extraer el token de .bashrc
GITEA_TOKEN=$(grep -oP "export GITEA_TOKEN=['\"]?\K[^'\"]*" "$HOME/.bashrc" 2>/dev/null || echo "")
fi
MAX_WAIT_SECONDS=600 # 10 minutos
POLL_INTERVAL=10 # Consultar cada 10 segundos
# Leer el input JSON del hook
INPUT=$(cat)
# Verificar si el comando fue un git push
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if [[ ! "$COMMAND" =~ git[[:space:]]+push ]]; then
# No fue un git push, salir sin hacer nada
exit 0
fi
# Verificar que existe el token
if [ -z "$GITEA_TOKEN" ]; then
cat <<EOF
{
"decision": "block",
"reason": "⚠️ Git push exitoso, pero no puedo monitorear la Gitea Action: falta GITEA_TOKEN.\n\nConfigura el token en ~/.bashrc o ~/.zshrc:\nexport GITEA_TOKEN='tu_token_aqui'"
}
EOF
exit 0
fi
# Función para verificar si las actions están habilitadas
check_actions_enabled() {
local repo_info=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/$OWNER/$REPO")
echo "$repo_info" | jq -r '.has_actions // false'
}
# Función para consultar el estado de la última action
get_latest_action_status() {
local response=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/$OWNER/$REPO/actions/tasks?limit=1")
# Verificar si hay un error de permisos
if echo "$response" | jq -e '.message' > /dev/null 2>&1; then
echo "ERROR: $(echo "$response" | jq -r '.message')" >&2
echo ""
return 1
fi
echo "$response" | jq -r '.workflow_runs[0] // .data[0] // empty'
}
# Función para formatear el resultado
format_result() {
local status="$1"
local task_data="$2"
local id=$(echo "$task_data" | jq -r '.id // "N/A"')
local workflow_name=$(echo "$task_data" | jq -r '.name // "N/A"')
local workflow_file=$(echo "$task_data" | jq -r '.workflow_id // "N/A"')
local run_number=$(echo "$task_data" | jq -r '.run_number // "N/A"')
local event=$(echo "$task_data" | jq -r '.event // "N/A"')
local branch=$(echo "$task_data" | jq -r '.head_branch // "N/A"')
local title=$(echo "$task_data" | jq -r '.display_title // "N/A"')
local created=$(echo "$task_data" | jq -r '.created_at // "N/A"')
local started=$(echo "$task_data" | jq -r '.run_started_at // .started_at // "N/A"')
local updated=$(echo "$task_data" | jq -r '.updated_at // .stopped_at // "N/A"')
local commit=$(echo "$task_data" | jq -r '.head_sha[0:8] // "N/A"')
local run_url=$(echo "$task_data" | jq -r '.url // ""')
# Calcular duración si es posible
local duration="N/A"
if [[ "$started" != "N/A" && "$updated" != "N/A" ]]; then
local start_ts=$(date -d "$started" +%s 2>/dev/null || echo "0")
local end_ts=$(date -d "$updated" +%s 2>/dev/null || echo "0")
if [[ $start_ts -gt 0 && $end_ts -gt 0 ]]; then
local diff=$((end_ts - start_ts))
if [[ $diff -lt 60 ]]; then
duration="${diff}s"
else
local mins=$((diff / 60))
local secs=$((diff % 60))
duration="${mins}m ${secs}s"
fi
fi
fi
case "$status" in
success)
local emoji="✅"
local msg="EXITOSO"
;;
failure)
local emoji="❌"
local msg="FALLÓ"
;;
cancelled)
local emoji="🚫"
local msg="CANCELADO"
;;
*)
local emoji="⚠️"
local msg="DESCONOCIDO ($status)"
;;
esac
# Construir URL de logs si no está disponible
if [[ -z "$run_url" || "$run_url" == "null" ]]; then
run_url="$GITEA_URL/$OWNER/$REPO/actions/runs/$id"
fi
cat <<EOF
{
"decision": "block",
"reason": "$emoji Gitea Action completada: $msg\n\n📋 Detalles:\n • Workflow: $workflow_name ($workflow_file)\n • Run #$run_number\n • Evento: $event\n • Branch: $branch\n • Commit: $commit\n • Título: $title\n • Duración: $duration\n • Iniciado: $started\n • Finalizado: $updated\n\n🔗 Ver logs completos:\n $run_url"
}
EOF
}
# Verificar si las actions están habilitadas
echo "🔍 Verificando si Actions están habilitadas..." >&2
ACTIONS_ENABLED=$(check_actions_enabled)
if [[ "$ACTIONS_ENABLED" != "true" ]]; then
cat <<EOF
{
"decision": "block",
"reason": "⚠️ Git push exitoso, pero las Gitea Actions NO están habilitadas en este repositorio.\n\n📝 Para habilitar Actions:\n1. Ve a: $GITEA_URL/$OWNER/$REPO/settings\n2. Busca la sección 'Actions' o 'Workflows'\n3. Activa las Actions\n\nLuego podrás ver tus workflows en:\n$GITEA_URL/$OWNER/$REPO/actions"
}
EOF
exit 0
fi
# Notificar que empezamos a monitorear
echo "🔄 Monitoreando Gitea Action (máximo ${MAX_WAIT_SECONDS}s)..." >&2
# Esperar un poco antes de la primera consulta (dar tiempo a que Gitea cree la action)
sleep 5
# Polling loop
elapsed=0
while [ $elapsed -lt $MAX_WAIT_SECONDS ]; do
# Consultar el estado
TASK_DATA=$(get_latest_action_status)
if [ -z "$TASK_DATA" ]; then
echo "⏳ Esperando que Gitea cree la action... (${elapsed}s)" >&2
sleep $POLL_INTERVAL
elapsed=$((elapsed + POLL_INTERVAL))
continue
fi
STATUS=$(echo "$TASK_DATA" | jq -r '.status // "unknown"')
echo "📊 Estado actual: $STATUS (${elapsed}s)" >&2
# Verificar si terminó
case "$STATUS" in
success|failure|cancelled)
# Action terminó!
format_result "$STATUS" "$TASK_DATA"
exit 0
;;
running|pending|waiting)
# Todavía corriendo
sleep $POLL_INTERVAL
elapsed=$((elapsed + POLL_INTERVAL))
;;
*)
# Estado desconocido
echo "⚠️ Estado desconocido: $STATUS" >&2
sleep $POLL_INTERVAL
elapsed=$((elapsed + POLL_INTERVAL))
;;
esac
done
# Timeout alcanzado
cat <<EOF
{
"decision": "block",
"reason": "⏱️ Timeout: La Gitea Action todavía está corriendo después de ${MAX_WAIT_SECONDS}s.\n\n🔗 Verifica el estado manualmente en:\n$GITEA_URL/$OWNER/$REPO/actions"
}
EOF

View File

@@ -4,5 +4,17 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
SUPABASE_ANON_KEY=your-anon-key SUPABASE_ANON_KEY=your-anon-key
# Authentik Configuration # Authentik Configuration
AUTHENTIK_URL=https://authentik.nucleoriofrio.com NUXT_PUBLIC_AUTHENTIK_URL=https://authentik.nucleoriofrio.com
AUTHENTIK_APP_SLUG=your-app-slug
# PostgreSQL Configuration
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-secure-password
POSTGRES_DB=analitica
# PostgREST Configuration
PGRST_DB_AUTHENTICATOR_PASSWORD=your-authenticator-password
PGRST_DB_SCHEMA=public
PGRST_DB_ANON_ROLE=web_anon
PGRST_JWT_SECRET=your-jwt-secret-min-32-chars
PGRST_OPENAPI_SERVER_PROXY_URI=https://api.analitica.nucleoriofrio.com
NUXT_PUBLIC_POSTGREST_URL=https://api.analitica.nucleoriofrio.com

View File

@@ -0,0 +1,73 @@
name: build-and-deploy
on:
push:
branches: [ main, master ]
jobs:
#───────────────── build & push ─────────────────
build:
runs-on: docker
env:
REG: ${{ vars.REGISTRY_URL }}
APP_NAME: ${{ vars.APP_NAME }}
steps:
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build+push ${{ vars.APP_NAME }}
run: |
cd nuxt4-app
docker build -t $REG/${{ github.repository_owner }}/$APP_NAME:${{ github.sha }} -t $REG/${{ github.repository_owner }}/$APP_NAME:latest .
docker push $REG/${{ github.repository_owner }}/$APP_NAME:${{ github.sha }}
docker push $REG/${{ github.repository_owner }}/$APP_NAME:latest
#───────────────── deploy ─────────────────
deploy:
needs: build
runs-on: docker
env:
REG: ${{ vars.REGISTRY_URL }}
REPO_OWNER: ${{ github.repository_owner }}
APP_NAME: ${{ vars.APP_NAME }}
APP_DOMAIN: ${{ vars.APP_DOMAIN }}
# Variables sensibles de entorno desde secrets
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
PGRST_DB_AUTHENTICATOR_PASSWORD: ${{ secrets.PGRST_DB_AUTHENTICATOR_PASSWORD }}
PGRST_JWT_SECRET: ${{ secrets.PGRST_JWT_SECRET }}
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
# Variables públicas desde vars
POSTGRES_USER: ${{ vars.POSTGRES_USER }}
POSTGRES_DB: ${{ vars.POSTGRES_DB }}
PGRST_DB_SCHEMA: ${{ vars.PGRST_DB_SCHEMA }}
PGRST_DB_ANON_ROLE: ${{ vars.PGRST_DB_ANON_ROLE }}
PGRST_OPENAPI_SERVER_PROXY_URI: ${{ vars.PGRST_OPENAPI_SERVER_PROXY_URI }}
NUXT_PUBLIC_POSTGREST_URL: ${{ vars.NUXT_PUBLIC_POSTGREST_URL }}
NUXT_PUBLIC_AUTHENTIK_URL: ${{ vars.NUXT_PUBLIC_AUTHENTIK_URL }}
steps:
- uses: actions/checkout@v3
- name: Login to registry
run: docker login ${{ vars.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_PASSWORD }}
- name: Info about environment
run: |
echo " Deploying ${{ vars.APP_NAME }}"
echo " Domain: ${{ vars.APP_DOMAIN }}"
echo " Image: ${{ vars.REGISTRY_URL }}/${{ github.repository_owner }}/${{ vars.APP_NAME }}:latest"
echo " Network: principal"
- name: Pull fresh images used in compose
run: docker compose pull
- name: Clean up stack
run: docker compose --project-name $APP_NAME down
- name: Update stack
run: docker compose --project-name $APP_NAME up -d --remove-orphans --wait

197
POSTGRES_SETUP.md Normal file
View File

@@ -0,0 +1,197 @@
# Configuración de PostgreSQL y PostgREST
Esta aplicación utiliza PostgreSQL como base de datos principal y PostgREST para exponer las tablas y vistas como una API REST.
## Arquitectura
```
Airbyte → PostgreSQL → PostgREST → Nuxt App
```
- **Airbyte**: Sincroniza datos de diversas fuentes hacia PostgreSQL
- **PostgreSQL**: Base de datos principal que almacena todos los datos
- **PostgREST**: Expone las tablas y vistas de PostgreSQL como API REST
- **Nuxt App**: Consume los datos desde la API de PostgREST
## Configuración Inicial
### 1. Variables de Entorno
Copia el archivo `.env.example` a `.env` y configura las siguientes variables:
```bash
# PostgreSQL
POSTGRES_USER=postgres
POSTGRES_PASSWORD=tu-contraseña-segura
POSTGRES_DB=analitica
# PostgREST
PGRST_DB_AUTHENTICATOR_PASSWORD=tu-contraseña-authenticator
PGRST_DB_SCHEMA=public
PGRST_DB_ANON_ROLE=web_anon
PGRST_JWT_SECRET=tu-jwt-secret-minimo-32-caracteres
PGRST_OPENAPI_SERVER_PROXY_URI=https://api.analitica.nucleoriofrio.com
NUXT_PUBLIC_POSTGREST_URL=https://api.analitica.nucleoriofrio.com
```
### 2. Generar JWT Secret
El JWT secret debe tener al menos 32 caracteres. Puedes generarlo con:
```bash
openssl rand -base64 32
```
### 3. Iniciar los Servicios
```bash
docker-compose up -d
```
Esto iniciará:
- PostgreSQL en el puerto interno 5432
- PostgREST expuesto vía Traefik en `api.analitica.nucleoriofrio.com`
- La aplicación Nuxt en `analitica.nucleoriofrio.com`
## Estructura de Base de Datos
### Esquemas
Por defecto, PostgREST expone el esquema `public`. Puedes cambiar esto modificando la variable `PGRST_DB_SCHEMA`.
### Roles y Permisos
Se crean automáticamente dos roles:
1. **web_anon**: Rol anónimo con permisos de solo lectura
- Se usa para consultas sin autenticación
- Solo tiene permiso SELECT en las tablas
2. **authenticator**: Rol de conexión para PostgREST
- Usado por PostgREST para conectarse a la base de datos
- Puede cambiar al rol web_anon según sea necesario
### Scripts de Inicialización
Los scripts en `/init-db` se ejecutan automáticamente cuando se crea la base de datos por primera vez:
- `01-init.sql`: Crea el rol web_anon y configura permisos
- `02-create-authenticator.sh`: Crea el rol authenticator
## Uso de PostgREST
### API REST Automática
PostgREST expone automáticamente todas las tablas y vistas del esquema configurado como endpoints REST:
#### Listar registros
```bash
GET https://api.analitica.nucleoriofrio.com/nombre_tabla
```
#### Filtrar registros
```bash
GET https://api.analitica.nucleoriofrio.com/nombre_tabla?columna=eq.valor
```
#### Seleccionar columnas específicas
```bash
GET https://api.analitica.nucleoriofrio.com/nombre_tabla?select=columna1,columna2
```
#### Ordenar resultados
```bash
GET https://api.analitica.nucleoriofrio.com/nombre_tabla?order=columna.desc
```
### Documentación de la API
PostgREST genera automáticamente documentación OpenAPI en:
```
https://api.analitica.nucleoriofrio.com/
```
## Airbyte → PostgreSQL
### Configurar Destino en Airbyte
1. En Airbyte, crea un nuevo destino de tipo PostgreSQL
2. Usa los siguientes parámetros de conexión:
- **Host**: `analiticanucleo-postgres` (si Airbyte está en la misma red Docker) o la IP del servidor
- **Port**: `5432`
- **Database**: El valor de `POSTGRES_DB`
- **User**: El valor de `POSTGRES_USER`
- **Password**: El valor de `POSTGRES_PASSWORD`
- **Schema**: `public` o el esquema que prefieras
3. Configura tus conexiones en Airbyte para sincronizar datos hacia este destino
## Mantenimiento
### Ver logs de PostgreSQL
```bash
docker logs analiticanucleo-postgres
```
### Ver logs de PostgREST
```bash
docker logs analiticanucleo-postgrest
```
### Conectarse a PostgreSQL
```bash
docker exec -it analiticanucleo-postgres psql -U postgres -d analitica
```
### Reiniciar la base de datos (¡CUIDADO!)
```bash
docker-compose down
docker volume rm analiticanucleo_postgres_data
docker-compose up -d
```
## Crear Vistas para la API
Para exponer datos procesados, crea vistas en PostgreSQL:
```sql
CREATE VIEW vista_resumen AS
SELECT
columna1,
COUNT(*) as total,
AVG(columna2) as promedio
FROM tabla_origen
GROUP BY columna1;
-- Dar permisos al rol web_anon
GRANT SELECT ON vista_resumen TO web_anon;
```
Esta vista estará automáticamente disponible en:
```
GET https://api.analitica.nucleoriofrio.com/vista_resumen
```
## Seguridad
### Autenticación JWT (Opcional)
Para proteger endpoints específicos, puedes configurar autenticación JWT. PostgREST validará automáticamente los tokens JWT firmados con `PGRST_JWT_SECRET`.
### Permisos Granulares
Puedes crear roles adicionales con permisos más específicos:
```sql
CREATE ROLE usuario_lectura NOLOGIN;
GRANT USAGE ON SCHEMA public TO usuario_lectura;
GRANT SELECT ON tabla_especifica TO usuario_lectura;
GRANT usuario_lectura TO authenticator;
```
## Próximos Pasos
1. Configurar Airbyte para sincronizar datos hacia PostgreSQL
2. Crear vistas y tablas según tus necesidades
3. Consumir la API desde la aplicación Nuxt
4. (Opcional) Configurar autenticación JWT para endpoints protegidos

View File

@@ -1,11 +1,55 @@
version: '3.8' version: '3.8'
services: services:
postgres:
image: postgres:16-alpine
container_name: analiticanucleo-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB:-analitica}
- PGRST_DB_AUTHENTICATOR_PASSWORD=${PGRST_DB_AUTHENTICATOR_PASSWORD}
ports:
- "3000:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
networks:
- principal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
postgrest:
image: postgrest/postgrest:latest
container_name: analiticanucleo-postgrest
restart: unless-stopped
environment:
- PGRST_DB_URI=postgres://authenticator:${PGRST_DB_AUTHENTICATOR_PASSWORD}@postgres:5432/${POSTGRES_DB:-analitica}
- PGRST_DB_SCHEMA=${PGRST_DB_SCHEMA:-public}
- PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-web_anon}
- PGRST_JWT_SECRET=${PGRST_JWT_SECRET}
- PGRST_OPENAPI_SERVER_PROXY_URI=${PGRST_OPENAPI_SERVER_PROXY_URI:-https://api.analitica.nucleoriofrio.com}
depends_on:
postgres:
condition: service_healthy
networks:
- principal
labels:
- "traefik.enable=true"
- "traefik.http.routers.analiticanucleo-api.rule=Host(`api.analitica.nucleoriofrio.com`)"
- "traefik.http.routers.analiticanucleo-api.entrypoints=websecure"
- "traefik.http.routers.analiticanucleo-api.tls=true"
- "traefik.http.routers.analiticanucleo-api.tls.certresolver=letsencrypt"
- "traefik.http.services.analiticanucleo-api.loadbalancer.server.port=3000"
- "traefik.docker.network=principal"
nuxt-app: nuxt-app:
build: image: ${REG}/${REPO_OWNER}/${APP_NAME}:latest
context: . container_name: ${APP_NAME}
dockerfile: Dockerfile
container_name: analiticanucleo-nuxt-app
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=production - NODE_ENV=production
@@ -18,19 +62,37 @@ services:
- NEXT_PUBLIC_SUPABASE_URL=${SUPABASE_URL} - NEXT_PUBLIC_SUPABASE_URL=${SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} - NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
# Authentik configuration # Authentik configuration
- NUXT_PUBLIC_AUTHENTIK_URL=${AUTHENTIK_URL} - NUXT_PUBLIC_AUTHENTIK_URL=${NUXT_PUBLIC_AUTHENTIK_URL:-https://authentik.nucleoriofrio.com}
- NUXT_PUBLIC_AUTHENTIK_APP_SLUG=${AUTHENTIK_APP_SLUG} # PostgREST API URL
- NUXT_PUBLIC_POSTGREST_URL=${NUXT_PUBLIC_POSTGREST_URL:-https://api.analitica.nucleoriofrio.com}
depends_on:
- postgrest
networks: networks:
- principal - principal
- traefik-network
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.analiticanucleo.rule=Host(`analitica.nucleoriofrio.com`)"
- "traefik.http.routers.analiticanucleo.entrypoints=websecure"
- "traefik.http.routers.analiticanucleo.tls=true"
- "traefik.http.routers.analiticanucleo.tls.certresolver=letsencrypt"
- "traefik.http.services.analiticanucleo.loadbalancer.server.port=3000"
- "traefik.docker.network=principal" - "traefik.docker.network=principal"
# Service
- "traefik.http.services.${APP_NAME}.loadbalancer.server.port=3000"
# Router principal con Authentik Forward Auth
- "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_DOMAIN}`)"
- "traefik.http.routers.${APP_NAME}.entrypoints=websecure"
- "traefik.http.routers.${APP_NAME}.tls=true"
- "traefik.http.routers.${APP_NAME}.tls.certresolver=letsencrypt"
- "traefik.http.routers.${APP_NAME}.service=${APP_NAME}"
- "traefik.http.routers.${APP_NAME}.middlewares=authentik-forward-auth@file,${APP_NAME}-headers"
# Custom headers middleware
- "traefik.http.middlewares.${APP_NAME}-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
networks: networks:
principal: principal:
external: true external: true
traefik-network:
external: true
volumes:
postgres_data:

View File

@@ -1,17 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
const { user, loading, fetchUser, logout } = useAuth() const { user, isAuthenticated, logout, goToProfile } = useAuthentik()
// Estado para el dropdown // Estado para el dropdown
const isOpen = ref(false) const isOpen = ref(false)
// Cargar usuario al montar
onMounted(() => {
fetchUser()
})
// Computed para el avatar del usuario con gradiente dinámico // Computed para el avatar del usuario con gradiente dinámico
const userAvatar = computed(() => ({ const userAvatar = computed(() => ({
src: user.value?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=random&bold=true&format=svg`, src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=random&bold=true&format=svg`,
alt: user.value?.name || user.value?.username || 'User' alt: user.value?.name || user.value?.username || 'User'
})) }))
@@ -72,7 +67,7 @@ const items = computed(() => [
<template> <template>
<UDropdownMenu <UDropdownMenu
v-if="user?.authenticated && !loading" v-if="isAuthenticated"
v-model:open="isOpen" v-model:open="isOpen"
:items="items" :items="items"
:ui="{ :ui="{
@@ -194,18 +189,4 @@ const items = computed(() => [
</div> </div>
</template> </template>
</UDropdownMenu> </UDropdownMenu>
<div
v-else-if="loading"
class="relative"
>
<USkeleton
class="h-9 w-9"
:ui="{
rounded: 'rounded-full',
background: 'bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800'
}"
/>
<div class="absolute inset-0 rounded-full bg-gradient-to-br from-primary-500/20 to-transparent animate-pulse" />
</div>
</template> </template>

View File

@@ -50,7 +50,7 @@
</template> </template>
<template #footer="{ collapsed: isCollapsed }"> <template #footer="{ collapsed: isCollapsed }">
<div v-if="user?.authenticated && !loading" class="space-y-3"> <div v-if="isAuthenticated" class="space-y-3">
<!-- User Profile Section --> <!-- User Profile Section -->
<div <div
v-if="!isCollapsed" v-if="!isCollapsed"
@@ -212,17 +212,6 @@
</UButton> </UButton>
</div> </div>
</div> </div>
<!-- Loading State -->
<div v-else-if="loading">
<div v-if="!isCollapsed" class="space-y-2">
<USkeleton class="h-14 w-full" :ui="{ rounded: 'rounded-lg', background: 'bg-gray-200/60 dark:bg-gray-800/60' }" />
<USkeleton class="h-28 w-full" :ui="{ rounded: 'rounded-lg', background: 'bg-gray-200/60 dark:bg-gray-800/60' }" />
</div>
<div v-else class="flex flex-col items-center gap-3">
<USkeleton class="h-9 w-9" :ui="{ rounded: 'rounded-lg', background: 'bg-gray-200/60 dark:bg-gray-800/60' }" />
</div>
</div>
</template> </template>
</UDashboardSidebar> </UDashboardSidebar>
</template> </template>
@@ -282,16 +271,11 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
} }
]) ])
const { user, loading, fetchUser, logout } = useAuth() const { user, isAuthenticated, logout } = useAuthentik()
// Cargar usuario al montar
onMounted(() => {
fetchUser()
})
// Computed para el avatar del usuario // Computed para el avatar del usuario
const userAvatar = computed(() => ({ const userAvatar = computed(() => ({
src: user.value?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=3b82f6&color=fff&bold=true&format=svg`, src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=3b82f6&color=fff&bold=true&format=svg`,
alt: user.value?.name || user.value?.username || 'User' alt: user.value?.name || user.value?.username || 'User'
})) }))
</script> </script>

View File

@@ -1,48 +0,0 @@
export interface AuthUser {
username: string | null
email: string | null
name: string | null
uid: string | null
groups: string[]
authenticated: boolean
}
export const useAuth = () => {
const user = useState<AuthUser | null>('auth-user', () => null)
const loading = useState<boolean>('auth-loading', () => false)
const fetchUser = async () => {
loading.value = true
try {
const data = await $fetch<AuthUser>('/api/auth/user')
user.value = data
} catch (error) {
console.error('Error fetching user:', error)
user.value = null
} finally {
loading.value = false
}
}
const logout = () => {
// Limpiar estado local
user.value = null
loading.value = false
// Obtener configuración de Authentik desde variables de entorno
const config = useRuntimeConfig()
const authentikUrl = config.public.authentikUrl || 'https://authentik.nucleoriofrio.com'
const appSlug = config.public.authentikAppSlug || 'devserver'
// Redirigir al endpoint de logout de Authentik con el slug de la aplicación
// Esto cierra la sesión completa de Authentik (OIDC end-session)
window.location.href = `${authentikUrl}/application/o/${appSlug}/end-session/`
}
return {
user: readonly(user),
loading: readonly(loading),
fetchUser,
logout
}
}

View File

@@ -0,0 +1,220 @@
/**
* Composable para leer información de usuario de Authentik
* Los headers son inyectados por Authentik Proxy Outpost
*
* Documentación de headers disponibles:
* - x-authentik-username: Username del usuario
* - x-authentik-email: Email del usuario
* - x-authentik-name: Nombre completo del usuario
* - x-authentik-uid: UID único del usuario
* - x-authentik-groups: Grupos separados por |
* - x-authentik-meta-app: Slug de la aplicación en Authentik
* - x-authentik-meta-outpost: Nombre del outpost
* - Nota: Los roles RBAC son internos de Authentik y no se exponen via headers
*/
interface AuthentikUser {
username: string
email: string | undefined
name: string | undefined
groups: string[]
uid: string | undefined
avatar: string
// Metadata de la aplicación y outpost
appSlug?: string
outpostName?: string
}
interface AuthStatusResponse {
authenticated: boolean
user?: {
username: string
name?: string
}
}
export const useAuthentik = () => {
// Leer headers en el servidor y almacenarlos en state
const authentikUser = useState<AuthentikUser | null>('authentikUser', () => {
// Solo en el servidor, leer los headers
if (import.meta.server) {
const headers = useRequestHeaders()
const username = headers['x-authentik-username']
const email = headers['x-authentik-email']
const name = headers['x-authentik-name']
const groups = headers['x-authentik-groups']
const uid = headers['x-authentik-uid']
const appSlug = headers['x-authentik-meta-app']
const outpostName = headers['x-authentik-meta-outpost']
// Si no hay username, el usuario no está autenticado
if (!username) {
return null
}
return {
username,
email,
name,
groups: groups ? groups.split('|').filter(g => g.trim()) : [],
uid,
appSlug,
outpostName,
// Generar avatar URL usando UI Avatars
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(name || username)}&background=random&size=128`
}
}
return null
})
const user = computed(() => authentikUser.value)
const isAuthenticated = computed(() => !!user.value)
const logout = () => {
// Logout completo: invalida la sesión de Authentik completamente
// Esto cierra sesión en todas las aplicaciones
const authentikUrl = useRuntimeConfig().public.authentikUrl || 'https://authentik.nucleoriofrio.com'
navigateTo(`${authentikUrl}/flows/-/default/invalidation/`, { external: true })
}
const goToProfile = () => {
// URL de perfil de Authentik
const authentikUrl = useRuntimeConfig().public.authentikUrl || 'https://authentik.nucleoriofrio.com'
navigateTo(`${authentikUrl}/if/user/`, { external: true, open: { target: '_blank' } })
}
const checkSessionStatus = async () => {
const toast = useToast()
// Verificar si está offline primero
if (!navigator.onLine) {
toast.add({
title: 'Modo Offline',
description: 'No se puede validar sesión sin conexión',
color: 'neutral',
icon: 'i-heroicons-wifi'
})
return
}
// Mostrar toast de "verificando..."
toast.add({
title: 'Verificando sesión...',
description: 'Consultando estado en Authentik',
color: 'info',
icon: 'i-heroicons-arrow-path'
})
try {
// Consultar el endpoint de API que verifica contra Authentik
const response = await $fetch<AuthStatusResponse>('/api/auth/status')
if (response.authenticated && response.user) {
// Sesión activa en Authentik
toast.add({
title: 'Sesión Activa',
description: `Conectado como: ${response.user.name || response.user.username}`,
color: 'success',
icon: 'i-heroicons-check-circle'
})
} else {
// Sin sesión en Authentik
toast.add({
title: 'Sin Sesión',
description: 'No hay sesión activa en Authentik',
color: 'warning',
icon: 'i-heroicons-exclamation-triangle',
actions: [{
label: 'Iniciar Sesión',
onClick: () => {
// Recargar la página forzará a Authentik a redirigir al login
window.location.reload()
}
}]
})
}
} catch (error: unknown) {
// Verificar si está offline ahora (pudo desconectarse durante la petición)
if (!navigator.onLine) {
toast.add({
title: 'Modo Offline',
description: 'No se puede validar sesión sin conexión',
color: 'neutral',
icon: 'i-heroicons-wifi'
})
return
}
// Si el error es por redirect de Authentik (CORS/fetch error), significa que no hay sesión
// Authentik redirige a login cuando no hay sesión válida, causando error CORS en fetch
const errorMessage = (error as Error)?.message || String(error)
const isCorsOrRedirectError = errorMessage.includes('Failed to fetch') ||
errorMessage.includes('CORS') ||
(error as any)?.statusCode === 302
if (isCorsOrRedirectError) {
// Interpretar como sesión expirada/inválida
toast.add({
title: 'Sin Sesión',
description: 'No hay sesión activa en Authentik',
color: 'warning',
icon: 'i-heroicons-exclamation-triangle',
actions: [{
label: 'Iniciar Sesión',
onClick: () => {
// Recargar la página forzará a Authentik a redirigir al login
window.location.reload()
}
}]
})
} else {
// Error real de red o servidor
toast.add({
title: 'Error',
description: 'No se pudo verificar el estado de la sesión',
color: 'error',
icon: 'i-heroicons-x-circle'
})
}
console.error('Error checking session status:', error)
}
}
/**
* Verifica si el usuario pertenece a un grupo específico (frontend)
* Lee los grupos desde el estado local (headers de Authentik)
*/
const hasGroup = (groupName: string): boolean => {
if (!user.value) return false
return user.value.groups.includes(groupName)
}
/**
* Verifica si el usuario pertenece a un grupo específico (backend)
* Consulta al servidor para validar contra Authentik
*/
const checkGroupBackend = async (groupName: string): Promise<boolean> => {
try {
const response = await $fetch<{ hasGroup: boolean }>(`/api/auth/check-group`, {
method: 'POST',
body: { groupName }
})
return response.hasGroup
} catch (error) {
console.error('Error checking group membership:', error)
return false
}
}
return {
user,
isAuthenticated,
logout,
goToProfile,
checkSessionStatus,
hasGroup,
checkGroupBackend
}
}

View File

@@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const { user } = useAuth() const { user, isAuthenticated } = useAuthentik()
definePageMeta({
middleware: 'auth'
})
</script> </script>
<template> <template>

View File

@@ -166,8 +166,7 @@ export default defineNuxtConfig({
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
}, },
public: { public: {
authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || '', authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com'
authentikAppSlug: process.env.NUXT_PUBLIC_AUTHENTIK_APP_SLUG || ''
} }
} }
}) })

View File

@@ -1,15 +0,0 @@
export default defineEventHandler((event) => {
const headers = getHeaders(event)
// Authentik envía información del usuario en headers específicos
const user = {
username: headers['x-authentik-username'] || null,
email: headers['x-authentik-email'] || null,
name: headers['x-authentik-name'] || null,
uid: headers['x-authentik-uid'] || null,
groups: headers['x-authentik-groups'] ? headers['x-authentik-groups'].split(',') : [],
authenticated: !!headers['x-authentik-username']
}
return user
})