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>
|
||||||
|
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<PlaybackControls
|
<AuthIndicator />
|
||||||
|
<PlaybackControls
|
||||||
@shuffle-changed="$emit('shuffle-changed', $event)"
|
@shuffle-changed="$emit('shuffle-changed', $event)"
|
||||||
@repeat-changed="$emit('repeat-changed', $event)"
|
@repeat-changed="$emit('repeat-changed', $event)"
|
||||||
/>
|
/>
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Music } from 'lucide-vue-next'
|
import { Music } from 'lucide-vue-next'
|
||||||
|
import AuthIndicator from './AuthIndicator.client.vue'
|
||||||
import ThemeToggle from './ThemeToggle.client.vue'
|
import ThemeToggle from './ThemeToggle.client.vue'
|
||||||
import PlaybackControls from './PlaybackControls.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')
|
const response = await $fetch<{ tracks: Track[] }>('/api/music')
|
||||||
this.tracks = response.tracks || []
|
this.tracks = response.tracks || []
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user