Files
RepoDructor/components/AuthIndicator.client.vue
josedario87 d31a715974
All checks were successful
build-and-deploy / build (push) Successful in 23s
build-and-deploy / deploy (push) Successful in 3s
feat: agregar dropdown de autenticación con opción de logout
- Dropdown aparece al hacer click cuando estás conectado
- Opciones: verificar estado y cerrar sesión
- Logout usa endpoint de Authentik (/outpost.goauthentik.io/sign_out)
- Desregistra Service Worker antes de logout
- Animación smooth del dropdown
- Flecha indicadora que rota al abrir/cerrar
- Diseño glassmorphism consistente con la app
2025-10-12 03:16:01 -06:00

511 lines
11 KiB
Vue

<template>
<div class="auth-indicator-container">
<button
class="auth-indicator"
:class="[authStatus, { checking: isCheckingAuth, 'dropdown-open': dropdownOpen }]"
@click="handleClick"
:title="tooltipText"
:disabled="isCheckingAuth"
>
<component
:is="iconComponent"
:size="20"
class="auth-icon"
/>
<span class="auth-status-text">{{ statusText }}</span>
<ChevronDown
v-if="authStatus === 'authenticated'"
:size="16"
class="dropdown-arrow"
:class="{ rotated: dropdownOpen }"
/>
</button>
<!-- Dropdown Menu -->
<Teleport to="body">
<transition name="dropdown">
<div
v-if="dropdownOpen && authStatus === 'authenticated'"
class="auth-dropdown glass"
:style="dropdownStyle"
@click.stop
>
<button class="dropdown-item" @click="handleVerifyAuth">
<RefreshCw :size="16" />
<span>Verificar estado</span>
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item danger" @click="handleLogout">
<LogOut :size="16" />
<span>Cerrar sesión</span>
</button>
</div>
</transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { UserCheck, UserX, Loader, ShieldAlert, ChevronDown, RefreshCw, LogOut } from 'lucide-vue-next'
import { useAuth } from '~/composables/useAuth'
import { useMusicStore } from '~/stores/music'
const {
isAuthenticated,
authChecked,
isCheckingAuth,
authStatus,
checkAuth,
triggerAuth,
logout,
markUnauthenticated,
setupVisibilityListener,
cleanupListeners
} = useAuth()
const musicStore = useMusicStore()
// Dropdown state
const dropdownOpen = ref(false)
const buttonRef = ref(null)
// Check auth on mount and setup listeners
onMounted(async () => {
// Asumimos autenticado inicialmente (si la página cargó, pasamos Authentik)
// NO hacemos checkAuth inicial para evitar conflictos con Service Worker y Authentik
console.log('[AuthIndicator] Mounted - assuming authenticated (page loaded successfully)')
setupVisibilityListener()
// POLLING DESHABILITADO: Causa conflictos con Authentik cuando no estamos autenticados
// En su lugar, confiamos en la detección reactiva de errores del musicStore
console.log('[AuthIndicator] Polling disabled - relying on reactive error detection')
/* CÓDIGO DESHABILITADO
const interval = setInterval(() => {
if (!document.hidden && isAuthenticated.value) {
checkAuth(false)
}
}, 60000)
onUnmounted(() => {
clearInterval(interval)
})
*/
})
// Cleanup listeners on unmount
onUnmounted(() => {
cleanupListeners()
})
// Watch for failed API requests that might indicate auth loss
watch(() => musicStore.error, (error, oldError) => {
// Solo actuar si es un nuevo error (no el mismo)
if (error && error !== oldError) {
console.log('[AuthIndicator] Music store error detected:', error)
// Detectar errores de autenticación (múltiples formas)
const isAuthError =
error.includes('401') ||
error.includes('403') ||
error.includes('Unauthorized') ||
error.includes('Forbidden') ||
error.includes('Failed to fetch') || // Authentik redirect genera esto
error.includes('no response') // Nuxt $fetch sin respuesta
if (isAuthError) {
// Auth error detected - mark as unauthenticated immediately
console.log('[AuthIndicator] Auth error detected, updating status')
markUnauthenticated()
}
}
}, { immediate: false })
// Watch loading state - cuando termina de cargar, verificar si hubo errores de auth
watch(() => musicStore.loading, (loading, wasLoading) => {
if (wasLoading && !loading && musicStore.error) {
// Terminó de cargar con error, verificar si es de auth
const error = musicStore.error
const isAuthError =
error.includes('401') ||
error.includes('403') ||
error.includes('Unauthorized') ||
error.includes('Forbidden') ||
error.includes('Failed to fetch') ||
error.includes('no response')
if (isAuthError) {
markUnauthenticated()
}
}
})
const iconComponent = computed(() => {
if (isCheckingAuth.value) return Loader
if (!authChecked.value) return Loader
switch (authStatus.value) {
case 'authenticated':
return UserCheck
case 'unauthenticated':
return UserX
default:
return ShieldAlert
}
})
const statusText = computed(() => {
if (isCheckingAuth.value) return 'Verificando...'
switch (authStatus.value) {
case 'authenticated':
return 'Conectado'
case 'unauthenticated':
return 'Reautenticar'
default:
return 'Verificando...'
}
})
const tooltipText = computed(() => {
if (isCheckingAuth.value) return 'Verificando autenticación...'
switch (authStatus.value) {
case 'authenticated':
return 'Estás autenticado. Click para verificar estado.'
case 'unauthenticated':
return 'Sesión expirada. Click para iniciar sesión de nuevo.'
default:
return 'Verificando estado de autenticación...'
}
})
// Dropdown positioning
const dropdownStyle = computed(() => {
if (!process.client) return {}
// Position dropdown below the button
const button = document.querySelector('.auth-indicator')
if (!button) return {}
const rect = button.getBoundingClientRect()
return {
position: 'fixed',
top: `${rect.bottom + 8}px`,
left: `${rect.left}px`,
minWidth: `${rect.width}px`
}
})
const toggleDropdown = () => {
dropdownOpen.value = !dropdownOpen.value
}
const closeDropdown = () => {
dropdownOpen.value = false
}
const handleClick = () => {
if (isCheckingAuth.value) return
if (authStatus.value === 'unauthenticated') {
// Forzar reload de la página para que Authentik intercepte
triggerAuth()
} else if (authStatus.value === 'authenticated') {
// Abrir dropdown
toggleDropdown()
}
}
const handleVerifyAuth = async () => {
closeDropdown()
await checkAuth(true)
}
const handleLogout = async () => {
closeDropdown()
await logout()
}
// Close dropdown when clicking outside
const handleClickOutside = (event) => {
const dropdown = document.querySelector('.auth-dropdown')
const button = document.querySelector('.auth-indicator')
if (dropdown && !dropdown.contains(event.target) &&
button && !button.contains(event.target)) {
closeDropdown()
}
}
// Setup click outside listener
onMounted(() => {
if (process.client) {
document.addEventListener('click', handleClickOutside)
}
})
onUnmounted(() => {
if (process.client) {
document.removeEventListener('click', handleClickOutside)
}
})
</script>
<style scoped>
.auth-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
position: relative;
overflow: hidden;
}
.auth-indicator::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--status-color);
opacity: 0;
transition: opacity 0.3s ease;
}
.auth-indicator:hover::before {
opacity: 1;
}
.auth-indicator:not(:disabled):hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.auth-indicator:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.auth-icon {
flex-shrink: 0;
color: var(--status-color);
transition: all 0.3s ease;
}
.auth-indicator.checking .auth-icon {
animation: spin 1s linear infinite;
}
.auth-status-text {
color: var(--text-primary);
font-weight: 500;
letter-spacing: 0.3px;
}
/* Status colors */
.auth-indicator.authenticated {
--status-color: #10b981;
border-color: rgba(16, 185, 129, 0.3);
}
.auth-indicator.authenticated:hover {
border-color: rgba(16, 185, 129, 0.5);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
}
.auth-indicator.unauthenticated {
--status-color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.auth-indicator.unauthenticated:hover {
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
.auth-indicator.unknown {
--status-color: #f59e0b;
border-color: rgba(245, 158, 11, 0.3);
}
.auth-indicator.checking {
--status-color: #3b82f6;
}
/* Animation for loading state */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Responsive design */
@media (max-width: 768px) {
.auth-status-text {
display: none;
}
.auth-indicator {
padding: 8px;
min-width: 40px;
justify-content: center;
}
}
/* Pulse effect for unauthenticated state */
.auth-indicator.unauthenticated {
animation: pulse-red 2s ease-in-out infinite;
}
@keyframes pulse-red {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
.auth-indicator {
transition: none;
animation: none;
}
.auth-indicator.checking .auth-icon {
animation: none;
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.auth-indicator {
border-width: 2px;
}
.auth-indicator.authenticated {
background: rgba(16, 185, 129, 0.2);
}
.auth-indicator.unauthenticated {
background: rgba(239, 68, 68, 0.2);
}
}
/* Container for dropdown positioning */
.auth-indicator-container {
position: relative;
display: inline-block;
}
/* Dropdown arrow */
.dropdown-arrow {
flex-shrink: 0;
color: var(--text-secondary);
transition: transform 0.3s ease;
margin-left: 4px;
}
.dropdown-arrow.rotated {
transform: rotate(180deg);
}
/* Dropdown Menu */
.auth-dropdown {
position: fixed;
z-index: 10000;
min-width: 200px;
border-radius: 12px;
background: var(--bg-secondary);
backdrop-filter: blur(50px);
-webkit-backdrop-filter: blur(50px);
color: var(--text-primary);
border: 1px solid var(--border-glass);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
padding: 8px;
overflow: hidden;
}
.dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: transparent;
border: none;
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
}
.dropdown-item:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateX(2px);
}
.dropdown-item.danger {
color: #ef4444;
}
.dropdown-item.danger:hover {
background: rgba(239, 68, 68, 0.15);
}
.dropdown-item svg {
flex-shrink: 0;
}
.dropdown-divider {
height: 1px;
background: var(--border-glass);
margin: 6px 0;
}
/* Dropdown animation */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from {
opacity: 0;
transform: translateY(-8px) scale(0.95);
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
/* Responsive */
@media (max-width: 768px) {
.auth-dropdown {
min-width: 180px;
}
}
</style>