feat: migrar a useAuthentik y configurar CI/CD con Gitea Actions
- 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:
152
.claude/hooks/README.md
Normal file
152
.claude/hooks/README.md
Normal 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`.
|
||||||
197
.claude/hooks/monitor-gitea-action.sh
Executable file
197
.claude/hooks/monitor-gitea-action.sh
Executable 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
|
||||||
16
.env.example
16
.env.example
@@ -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
|
||||||
|
|||||||
73
.gitea/workflows/build-and-deploy.yml
Normal file
73
.gitea/workflows/build-and-deploy.yml
Normal 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
197
POSTGRES_SETUP.md
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
220
nuxt4-app/app/composables/useAuthentik.ts
Normal file
220
nuxt4-app/app/composables/useAuthentik.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user