indicador de conexion y logging
This commit is contained in:
251
components/AuthIndicator.client.vue
Normal file
251
components/AuthIndicator.client.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<button
|
||||
class="auth-indicator"
|
||||
:class="[authStatus, { checking: isCheckingAuth }]"
|
||||
@click="handleClick"
|
||||
:title="tooltipText"
|
||||
:disabled="isCheckingAuth"
|
||||
>
|
||||
<component
|
||||
:is="iconComponent"
|
||||
:size="20"
|
||||
class="auth-icon"
|
||||
/>
|
||||
<span class="auth-status-text">{{ statusText }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { UserCheck, UserX, Loader, ShieldAlert } from 'lucide-vue-next'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useMusicStore } from '~/stores/music'
|
||||
|
||||
const { isAuthenticated, authChecked, isCheckingAuth, authStatus, checkAuth, triggerAuth } = useAuth()
|
||||
const musicStore = useMusicStore()
|
||||
|
||||
// Check auth on mount
|
||||
onMounted(async () => {
|
||||
await checkAuth()
|
||||
})
|
||||
|
||||
// Watch for failed API requests that might indicate auth loss
|
||||
watch(() => musicStore.error, (error) => {
|
||||
if (error && (error.includes('401') || error.includes('403') || error.includes('Unauthorized'))) {
|
||||
// Auth might have expired, recheck
|
||||
checkAuth()
|
||||
}
|
||||
})
|
||||
|
||||
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 'Sin conexión'
|
||||
default:
|
||||
return 'Desconocido'
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (isCheckingAuth.value) return 'Verificando autenticación...'
|
||||
|
||||
switch (authStatus.value) {
|
||||
case 'authenticated':
|
||||
return 'Estás autenticado. Puedes descargar música.'
|
||||
case 'unauthenticated':
|
||||
return 'No autenticado. Click para iniciar sesión.'
|
||||
default:
|
||||
return 'Estado de autenticación desconocido'
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (isCheckingAuth.value) return
|
||||
|
||||
if (authStatus.value === 'unauthenticated') {
|
||||
// Redirect to trigger Authentik
|
||||
triggerAuth()
|
||||
} else if (authStatus.value === 'authenticated') {
|
||||
// Re-check auth status
|
||||
checkAuth()
|
||||
}
|
||||
}
|
||||
</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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,8 @@
|
||||
</div>
|
||||
|
||||
<div class="header-controls">
|
||||
<PlaybackControls
|
||||
<AuthIndicator />
|
||||
<PlaybackControls
|
||||
@shuffle-changed="$emit('shuffle-changed', $event)"
|
||||
@repeat-changed="$emit('repeat-changed', $event)"
|
||||
/>
|
||||
@@ -34,6 +35,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Music } from 'lucide-vue-next'
|
||||
import AuthIndicator from './AuthIndicator.client.vue'
|
||||
import ThemeToggle from './ThemeToggle.client.vue'
|
||||
import PlaybackControls from './PlaybackControls.client.vue'
|
||||
|
||||
|
||||
81
composables/useAuth.ts
Normal file
81
composables/useAuth.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// Estado global compartido para auth
|
||||
const authChecked = ref(false)
|
||||
const isAuthenticated = ref(false)
|
||||
const isCheckingAuth = ref(false)
|
||||
const lastCheckTime = ref(0)
|
||||
|
||||
export const useAuth = () => {
|
||||
const checkAuth = async (force = false) => {
|
||||
// Evitar chequeos duplicados (cache de 30 segundos)
|
||||
const now = Date.now()
|
||||
if (!force && isCheckingAuth.value) return
|
||||
if (!force && authChecked.value && now - lastCheckTime.value < 30000) {
|
||||
return
|
||||
}
|
||||
|
||||
isCheckingAuth.value = true
|
||||
|
||||
try {
|
||||
// Intenta hacer fetch a un endpoint protegido
|
||||
const response = await fetch('/api/music', {
|
||||
method: 'HEAD', // Solo headers, no body
|
||||
credentials: 'include', // Include cookies for auth
|
||||
signal: AbortSignal.timeout(5000) // 5s timeout
|
||||
})
|
||||
|
||||
// Si responde 200, estamos autenticados
|
||||
// Si responde 401/403, no estamos autenticados
|
||||
isAuthenticated.value = response.ok
|
||||
authChecked.value = true
|
||||
lastCheckTime.value = now
|
||||
|
||||
console.log('[Auth] Status:', isAuthenticated.value ? 'authenticated' : 'unauthenticated')
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to check authentication status:', error)
|
||||
// En caso de error de red, mantenemos el último estado conocido si existe
|
||||
// Solo marcamos como no autenticado si es el primer chequeo
|
||||
if (!authChecked.value) {
|
||||
isAuthenticated.value = false
|
||||
}
|
||||
authChecked.value = true
|
||||
lastCheckTime.value = now
|
||||
} finally {
|
||||
isCheckingAuth.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const triggerAuth = () => {
|
||||
console.log('[Auth] Redirecting to trigger Authentik...')
|
||||
// Redirige a la página principal para que Authentik intercepte
|
||||
// Si ya estamos en /, forzamos reload
|
||||
if (window.location.pathname === '/') {
|
||||
window.location.reload()
|
||||
} else {
|
||||
window.location.href = '/'
|
||||
}
|
||||
}
|
||||
|
||||
const markUnauthenticated = () => {
|
||||
// Helper para marcar como no autenticado (útil cuando detectamos 401/403)
|
||||
isAuthenticated.value = false
|
||||
authChecked.value = true
|
||||
lastCheckTime.value = Date.now()
|
||||
}
|
||||
|
||||
const authStatus = computed(() => {
|
||||
if (!authChecked.value) return 'unknown'
|
||||
return isAuthenticated.value ? 'authenticated' : 'unauthenticated'
|
||||
})
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
authChecked,
|
||||
isCheckingAuth,
|
||||
authStatus,
|
||||
checkAuth,
|
||||
triggerAuth,
|
||||
markUnauthenticated
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,16 @@ export const useMusicStore = defineStore('music', {
|
||||
const response = await $fetch<{ tracks: Track[] }>('/api/music')
|
||||
this.tracks = response.tracks || []
|
||||
} catch (e: any) {
|
||||
this.error = e?.message || 'Failed to load tracks'
|
||||
const errorMsg = e?.message || 'Failed to load tracks'
|
||||
this.error = errorMsg
|
||||
|
||||
// Check if it's an auth error
|
||||
if (e?.statusCode === 401 || e?.statusCode === 403 ||
|
||||
errorMsg.includes('401') || errorMsg.includes('403') ||
|
||||
errorMsg.includes('Unauthorized')) {
|
||||
console.warn('[Music Store] Authentication error detected')
|
||||
// The useAuth composable will be notified via watch in components
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user