diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md new file mode 100644 index 0000000..d5a5b9e --- /dev/null +++ b/.claude/hooks/README.md @@ -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`. diff --git a/.claude/hooks/monitor-gitea-action.sh b/.claude/hooks/monitor-gitea-action.sh new file mode 100755 index 0000000..4bfd5eb --- /dev/null +++ b/.claude/hooks/monitor-gitea-action.sh @@ -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 < /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 <&2 +ACTIONS_ENABLED=$(check_actions_enabled) + +if [[ "$ACTIONS_ENABLED" != "true" ]]; then + cat <&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 < -const { user, loading, fetchUser, logout } = useAuth() +const { user, isAuthenticated, logout, goToProfile } = useAuthentik() // Estado para el dropdown const isOpen = ref(false) -// Cargar usuario al montar -onMounted(() => { - fetchUser() -}) - // Computed para el avatar del usuario con gradiente dinámico 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' })) @@ -72,7 +67,7 @@ const items = computed(() => [ diff --git a/nuxt4-app/app/components/app/AppSidebar.vue b/nuxt4-app/app/components/app/AppSidebar.vue index adf410a..1322fff 100644 --- a/nuxt4-app/app/components/app/AppSidebar.vue +++ b/nuxt4-app/app/components/app/AppSidebar.vue @@ -50,7 +50,7 @@ @@ -282,16 +271,11 @@ const navigationPrimary = computed(() => [ } ]) -const { user, loading, fetchUser, logout } = useAuth() - -// Cargar usuario al montar -onMounted(() => { - fetchUser() -}) +const { user, isAuthenticated, logout } = useAuthentik() // Computed para el avatar del usuario 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' })) diff --git a/nuxt4-app/app/composables/useAuth.ts b/nuxt4-app/app/composables/useAuth.ts deleted file mode 100644 index a3e02ed..0000000 --- a/nuxt4-app/app/composables/useAuth.ts +++ /dev/null @@ -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('auth-user', () => null) - const loading = useState('auth-loading', () => false) - - const fetchUser = async () => { - loading.value = true - try { - const data = await $fetch('/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 - } -} diff --git a/nuxt4-app/app/composables/useAuthentik.ts b/nuxt4-app/app/composables/useAuthentik.ts new file mode 100644 index 0000000..00e079f --- /dev/null +++ b/nuxt4-app/app/composables/useAuthentik.ts @@ -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', () => { + // 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('/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 => { + 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 + } +} diff --git a/nuxt4-app/app/pages/profile.vue b/nuxt4-app/app/pages/profile.vue index b4999d6..06e59c8 100644 --- a/nuxt4-app/app/pages/profile.vue +++ b/nuxt4-app/app/pages/profile.vue @@ -1,9 +1,5 @@