Implementar sistema completo de captura y gestión de avatares
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 57s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 57s
- Agregar CameraCapture.vue con soporte multi-dispositivo * Soporte para múltiples cámaras (frontal/trasera) * Manejo robusto de permisos y errores * Preview y confirmación de foto * Detección automática de capacidades del dispositivo - Crear endpoint /api/avatar/upload para subir avatares * Validación de tipo y tamaño de archivo * Almacenamiento en /public/avatars/ * Actualización de atributos en Authentik * Limpieza automática de avatares antiguos - Actualizar UserProfileForm con gestión de avatar * Integración con CameraCapture en modal * Preview del avatar actual con MsnAvatar * Opciones para cambiar y eliminar avatar - Actualizar useAuthentik para avatares personalizados * Carga de atributos completos del usuario * Soporte para avatar_url desde Authentik * Fallback a UI Avatars si no hay custom avatar
This commit is contained in:
440
nuxt4/app/components/CameraCapture.vue
Normal file
440
nuxt4/app/components/CameraCapture.vue
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
<template>
|
||||||
|
<div class="camera-capture">
|
||||||
|
<div v-if="!hasPermission && !error" class="camera-placeholder">
|
||||||
|
<UIcon name="i-heroicons-camera" class="w-16 h-16 text-gray-400" />
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-4">
|
||||||
|
{{ permissionMessage }}
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
v-if="!isRequestingPermission"
|
||||||
|
@click="requestCamera"
|
||||||
|
color="primary"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-camera" class="w-5 h-5" />
|
||||||
|
Activar cámara
|
||||||
|
</UButton>
|
||||||
|
<div v-else class="mt-4">
|
||||||
|
<UIcon name="i-heroicons-arrow-path" class="w-6 h-6 animate-spin text-primary-500" />
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Solicitando permisos...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="camera-error">
|
||||||
|
<UIcon name="i-heroicons-exclamation-triangle" class="w-16 h-16 text-error-500" />
|
||||||
|
<p class="text-error-600 dark:text-error-400 mt-4 font-semibold">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
{{ errorHelp }}
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
@click="retry"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5" />
|
||||||
|
Reintentar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!capturedImage" class="camera-active">
|
||||||
|
<div class="video-container">
|
||||||
|
<video
|
||||||
|
ref="videoElement"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
class="camera-video"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- Overlay con guías de encuadre -->
|
||||||
|
<div class="camera-overlay">
|
||||||
|
<div class="camera-guide"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="camera-controls">
|
||||||
|
<!-- Selector de cámara (si hay múltiples) -->
|
||||||
|
<UButton
|
||||||
|
v-if="cameras.length > 1"
|
||||||
|
@click="switchCamera"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="isSwitching"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5" />
|
||||||
|
Cambiar cámara
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Botón de captura -->
|
||||||
|
<UButton
|
||||||
|
@click="capture"
|
||||||
|
color="primary"
|
||||||
|
size="xl"
|
||||||
|
class="capture-button"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-camera" class="w-6 h-6" />
|
||||||
|
Capturar foto
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Botón de cancelar -->
|
||||||
|
<UButton
|
||||||
|
@click="cancel"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-x-mark" class="w-5 h-5" />
|
||||||
|
Cancelar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="camera-preview">
|
||||||
|
<div class="preview-container">
|
||||||
|
<img :src="capturedImage" alt="Foto capturada" class="preview-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-controls">
|
||||||
|
<UButton
|
||||||
|
@click="retake"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5" />
|
||||||
|
Tomar otra
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
@click="confirm"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-check" class="w-5 h-5" />
|
||||||
|
Usar esta foto
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas ref="canvasElement" style="display: none;"></canvas>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface CameraDevice {
|
||||||
|
deviceId: string
|
||||||
|
label: string
|
||||||
|
kind: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'capture', image: Blob): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||||
|
const canvasElement = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const stream = ref<MediaStream | null>(null)
|
||||||
|
const capturedImage = ref<string | null>(null)
|
||||||
|
const hasPermission = ref(false)
|
||||||
|
const isRequestingPermission = ref(false)
|
||||||
|
const isSwitching = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const errorHelp = ref<string>('')
|
||||||
|
const permissionMessage = ref('Necesitamos acceso a tu cámara para tomar una foto de perfil')
|
||||||
|
const cameras = ref<CameraDevice[]>([])
|
||||||
|
const currentCameraIndex = ref(0)
|
||||||
|
|
||||||
|
// Detectar capacidades del dispositivo
|
||||||
|
const getUserMedia = navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices)
|
||||||
|
const enumerateDevices = navigator.mediaDevices?.enumerateDevices?.bind(navigator.mediaDevices)
|
||||||
|
|
||||||
|
// Listar cámaras disponibles
|
||||||
|
const listCameras = async () => {
|
||||||
|
if (!enumerateDevices) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await enumerateDevices()
|
||||||
|
cameras.value = devices
|
||||||
|
.filter(device => device.kind === 'videoinput')
|
||||||
|
.map(device => ({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
label: device.label || `Cámara ${cameras.value.length + 1}`,
|
||||||
|
kind: device.kind
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing cameras:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solicitar acceso a la cámara
|
||||||
|
const requestCamera = async () => {
|
||||||
|
if (!getUserMedia) {
|
||||||
|
error.value = 'Tu navegador no soporta acceso a la cámara'
|
||||||
|
errorHelp.value = 'Por favor usa un navegador moderno como Chrome, Firefox o Safari'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isRequestingPermission.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Primero solicitar permisos básicos
|
||||||
|
const testStream = await getUserMedia({ video: true })
|
||||||
|
testStream.getTracks().forEach(track => track.stop())
|
||||||
|
|
||||||
|
// Listar cámaras disponibles
|
||||||
|
await listCameras()
|
||||||
|
|
||||||
|
// Iniciar con la cámara preferida
|
||||||
|
await startCamera()
|
||||||
|
|
||||||
|
hasPermission.value = true
|
||||||
|
} catch (err: any) {
|
||||||
|
handleCameraError(err)
|
||||||
|
} finally {
|
||||||
|
isRequestingPermission.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iniciar stream de cámara
|
||||||
|
const startCamera = async () => {
|
||||||
|
if (!getUserMedia) return
|
||||||
|
|
||||||
|
const constraints: MediaStreamConstraints = {
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
facingMode: currentCameraIndex.value === 0 ? 'user' : 'environment'
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si tenemos un deviceId específico, úsalo
|
||||||
|
if (cameras.value.length > 0 && cameras.value[currentCameraIndex.value]) {
|
||||||
|
const camera = cameras.value[currentCameraIndex.value]
|
||||||
|
constraints.video = {
|
||||||
|
...constraints.video,
|
||||||
|
deviceId: { exact: camera.deviceId }
|
||||||
|
} as MediaTrackConstraints
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stream.value = await getUserMedia(constraints)
|
||||||
|
|
||||||
|
if (videoElement.value) {
|
||||||
|
videoElement.value.srcObject = stream.value
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
handleCameraError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar errores de cámara
|
||||||
|
const handleCameraError = (err: any) => {
|
||||||
|
console.error('Camera error:', err)
|
||||||
|
|
||||||
|
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||||
|
error.value = 'Permiso denegado'
|
||||||
|
errorHelp.value = 'Por favor permite el acceso a la cámara en la configuración de tu navegador'
|
||||||
|
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
|
||||||
|
error.value = 'No se encontró ninguna cámara'
|
||||||
|
errorHelp.value = 'Asegúrate de que tu dispositivo tenga una cámara conectada'
|
||||||
|
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
|
||||||
|
error.value = 'La cámara está en uso'
|
||||||
|
errorHelp.value = 'Cierra otras aplicaciones que puedan estar usando la cámara'
|
||||||
|
} else if (err.name === 'OverconstrainedError') {
|
||||||
|
error.value = 'Configuración no compatible'
|
||||||
|
errorHelp.value = 'Tu cámara no soporta la resolución solicitada'
|
||||||
|
} else {
|
||||||
|
error.value = 'Error al acceder a la cámara'
|
||||||
|
errorHelp.value = err.message || 'Intenta recargar la página o usar otro navegador'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cambiar entre cámaras
|
||||||
|
const switchCamera = async () => {
|
||||||
|
if (cameras.value.length <= 1) return
|
||||||
|
|
||||||
|
isSwitching.value = true
|
||||||
|
|
||||||
|
// Detener stream actual
|
||||||
|
stopCamera()
|
||||||
|
|
||||||
|
// Cambiar a siguiente cámara
|
||||||
|
currentCameraIndex.value = (currentCameraIndex.value + 1) % cameras.value.length
|
||||||
|
|
||||||
|
// Iniciar nueva cámara
|
||||||
|
await startCamera()
|
||||||
|
|
||||||
|
isSwitching.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capturar foto
|
||||||
|
const capture = () => {
|
||||||
|
if (!videoElement.value || !canvasElement.value) return
|
||||||
|
|
||||||
|
const video = videoElement.value
|
||||||
|
const canvas = canvasElement.value
|
||||||
|
|
||||||
|
// Configurar canvas con el tamaño del video
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
|
||||||
|
// Dibujar frame actual del video
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Convertir a imagen
|
||||||
|
capturedImage.value = canvas.toDataURL('image/jpeg', 0.9)
|
||||||
|
|
||||||
|
// Detener cámara
|
||||||
|
stopCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reintentar captura
|
||||||
|
const retake = () => {
|
||||||
|
capturedImage.value = null
|
||||||
|
error.value = null
|
||||||
|
requestCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmar y enviar imagen
|
||||||
|
const confirm = () => {
|
||||||
|
if (!canvasElement.value || !capturedImage.value) return
|
||||||
|
|
||||||
|
canvasElement.value.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) {
|
||||||
|
emit('capture', blob)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
0.9
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelar
|
||||||
|
const cancel = () => {
|
||||||
|
stopCamera()
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reintentar
|
||||||
|
const retry = () => {
|
||||||
|
error.value = null
|
||||||
|
errorHelp.value = ''
|
||||||
|
hasPermission.value = false
|
||||||
|
requestCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detener cámara
|
||||||
|
const stopCamera = () => {
|
||||||
|
if (stream.value) {
|
||||||
|
stream.value.getTracks().forEach(track => track.stop())
|
||||||
|
stream.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup al desmontar
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopCamera()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.camera-capture {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-placeholder,
|
||||||
|
.camera-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-active,
|
||||||
|
.camera-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container,
|
||||||
|
.preview-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: var(--color-gray-900);
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-video,
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-guide {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 70%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls,
|
||||||
|
.preview-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.video-container,
|
||||||
|
.preview-container {
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls,
|
||||||
|
.preview-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls button,
|
||||||
|
.preview-controls button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -160,19 +160,51 @@
|
|||||||
<span class="field-help">Próximamente disponible</span>
|
<span class="field-help">Próximamente disponible</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Avatar URL (deshabilitado) -->
|
<!-- Avatar / Foto de perfil -->
|
||||||
<div class="form-field">
|
<div class="form-field full-width">
|
||||||
<label class="field-label">
|
<label class="field-label">
|
||||||
<UIcon name="i-heroicons-photo" class="w-4 h-4" />
|
<UIcon name="i-heroicons-camera" class="w-4 h-4" />
|
||||||
URL del avatar
|
Foto de perfil
|
||||||
</label>
|
</label>
|
||||||
<UInput
|
|
||||||
v-model="formData.avatar"
|
<div class="avatar-section">
|
||||||
placeholder="https://ejemplo.com/avatar.jpg"
|
<!-- Preview del avatar actual -->
|
||||||
disabled
|
<div class="avatar-preview">
|
||||||
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
<MsnAvatar
|
||||||
|
:src="currentAvatar"
|
||||||
|
:alt="user?.name || user?.username"
|
||||||
|
:presence-status="'online'"
|
||||||
|
:size="120"
|
||||||
/>
|
/>
|
||||||
<span class="field-help">Próximamente disponible</span>
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones de acción -->
|
||||||
|
<div class="avatar-actions">
|
||||||
|
<UButton
|
||||||
|
@click="showCamera = true"
|
||||||
|
color="primary"
|
||||||
|
:disabled="isUploading"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-camera" class="w-5 h-5" />
|
||||||
|
{{ currentAvatar ? 'Cambiar foto' : 'Tomar foto' }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="currentAvatar && currentAvatar.includes('/avatars/')"
|
||||||
|
@click="removeAvatar"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
:disabled="isUploading"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-trash" class="w-5 h-5" />
|
||||||
|
Eliminar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="field-help">
|
||||||
|
Usa la cámara para tomar una foto de perfil personalizada
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,6 +285,25 @@
|
|||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Modal de cámara -->
|
||||||
|
<UModal v-model="showCamera" :ui="{ width: 'max-w-3xl' }">
|
||||||
|
<div class="camera-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">
|
||||||
|
<UIcon name="i-heroicons-camera" class="w-6 h-6" />
|
||||||
|
Tomar foto de perfil
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-content">
|
||||||
|
<CameraCapture
|
||||||
|
@capture="handleAvatarCapture"
|
||||||
|
@cancel="showCamera = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -265,6 +316,8 @@ defineEmits(['close'])
|
|||||||
|
|
||||||
// Estado del formulario
|
// Estado del formulario
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const showCamera = ref(false)
|
||||||
|
|
||||||
// Datos del formulario
|
// Datos del formulario
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
@@ -279,6 +332,16 @@ const formData = ref({
|
|||||||
nucleoCode: ''
|
nucleoCode: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Avatar actual del usuario
|
||||||
|
const currentAvatar = ref(user.value?.avatar || '')
|
||||||
|
|
||||||
|
// Actualizar avatar cuando cambie el usuario
|
||||||
|
watch(() => user.value?.avatar, (newAvatar) => {
|
||||||
|
if (newAvatar) {
|
||||||
|
currentAvatar.value = newAvatar
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Enviar formulario
|
// Enviar formulario
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.value.name || !formData.value.email) {
|
if (!formData.value.name || !formData.value.email) {
|
||||||
@@ -324,6 +387,95 @@ const handleSubmit = async () => {
|
|||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manejar captura de avatar desde cámara
|
||||||
|
const handleAvatarCapture = async (imageBlob: Blob) => {
|
||||||
|
isUploading.value = true
|
||||||
|
showCamera.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Crear FormData para subir el archivo
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('avatar', imageBlob, 'avatar.jpg')
|
||||||
|
|
||||||
|
// Subir avatar
|
||||||
|
const response = await $fetch<{ success: boolean; avatarUrl: string; message: string }>('/api/avatar/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// Actualizar preview del avatar
|
||||||
|
currentAvatar.value = response.avatarUrl
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: 'Avatar actualizado',
|
||||||
|
description: response.message,
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-heroicons-check-circle',
|
||||||
|
actions: [{
|
||||||
|
label: 'Recargar',
|
||||||
|
onClick: () => window.location.reload()
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error uploading avatar:', error)
|
||||||
|
toast.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.data?.message || 'No se pudo subir el avatar',
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-heroicons-x-circle'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar avatar personalizado
|
||||||
|
const removeAvatar = async () => {
|
||||||
|
if (!confirm('¿Estás seguro de que quieres eliminar tu foto de perfil?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Actualizar Authentik para eliminar avatar_url
|
||||||
|
await $fetch('/api/authentik/user', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
name: formData.value.name,
|
||||||
|
email: formData.value.email,
|
||||||
|
removeAvatar: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Volver al avatar por defecto
|
||||||
|
currentAvatar.value = user.value?.avatar || ''
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: 'Avatar eliminado',
|
||||||
|
description: 'Se restauró el avatar por defecto. Recarga para ver los cambios.',
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-heroicons-check-circle',
|
||||||
|
actions: [{
|
||||||
|
label: 'Recargar',
|
||||||
|
onClick: () => window.location.reload()
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing avatar:', error)
|
||||||
|
toast.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'No se pudo eliminar el avatar',
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-heroicons-x-circle'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -413,6 +565,50 @@ const handleSubmit = async () => {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-modal {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -447,6 +643,15 @@ const handleSubmit = async () => {
|
|||||||
.form-actions {
|
.form-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-section {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -480,4 +685,13 @@ const handleSubmit = async () => {
|
|||||||
.dark .form-actions {
|
.dark .form-actions {
|
||||||
border-top-color: rgba(255, 255, 255, 0.1);
|
border-top-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .avatar-section {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .modal-title {
|
||||||
|
color: var(--color-gray-100) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface AuthentikUser {
|
|||||||
// Metadata de la aplicación y outpost
|
// Metadata de la aplicación y outpost
|
||||||
appSlug?: string
|
appSlug?: string
|
||||||
outpostName?: string
|
outpostName?: string
|
||||||
|
// Atributos personalizados
|
||||||
|
attributes?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthStatusResponse {
|
interface AuthStatusResponse {
|
||||||
@@ -61,8 +63,9 @@ export const useAuthentik = () => {
|
|||||||
uid,
|
uid,
|
||||||
appSlug,
|
appSlug,
|
||||||
outpostName,
|
outpostName,
|
||||||
// Generar avatar URL usando UI Avatars
|
// Generar avatar URL usando UI Avatars (se actualizará con avatar personalizado si existe)
|
||||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(name || username)}&background=random&size=128`
|
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(name || username)}&background=random&size=128`,
|
||||||
|
attributes: {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +75,32 @@ export const useAuthentik = () => {
|
|||||||
const user = computed(() => authentikUser.value)
|
const user = computed(() => authentikUser.value)
|
||||||
const isAuthenticated = computed(() => !!user.value)
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
|
|
||||||
|
// En el cliente, cargar atributos completos del usuario (incluyendo avatar personalizado)
|
||||||
|
const loadUserAttributes = async () => {
|
||||||
|
if (import.meta.client && authentikUser.value) {
|
||||||
|
try {
|
||||||
|
const userData = await $fetch<any>('/api/authentik/user')
|
||||||
|
|
||||||
|
if (userData && userData.attributes) {
|
||||||
|
// Actualizar atributos
|
||||||
|
authentikUser.value.attributes = userData.attributes
|
||||||
|
|
||||||
|
// Si hay avatar personalizado, usarlo
|
||||||
|
if (userData.attributes.avatar_url) {
|
||||||
|
authentikUser.value.avatar = userData.attributes.avatar_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading user attributes:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar en cliente al montar
|
||||||
|
if (import.meta.client && authentikUser.value) {
|
||||||
|
loadUserAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
// Logout completo: invalida la sesión de Authentik completamente
|
// Logout completo: invalida la sesión de Authentik completamente
|
||||||
// Esto cierra sesión en todas las aplicaciones
|
// Esto cierra sesión en todas las aplicaciones
|
||||||
|
|||||||
0
nuxt4/public/avatars/.gitkeep
Normal file
0
nuxt4/public/avatars/.gitkeep
Normal file
164
nuxt4/server/api/avatar/upload.post.ts
Normal file
164
nuxt4/server/api/avatar/upload.post.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* API endpoint para subir avatar del usuario
|
||||||
|
* POST /api/avatar/upload
|
||||||
|
*
|
||||||
|
* Recibe una imagen, la guarda en /public/avatars/ y actualiza Authentik
|
||||||
|
*/
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||||
|
const AVATARS_DIR = 'public/avatars'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const headers = getRequestHeaders(event)
|
||||||
|
|
||||||
|
// Verificar autenticación
|
||||||
|
const username = headers['x-authentik-username']
|
||||||
|
if (!username) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Usuario no autenticado'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Leer el body como multipart/form-data
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
|
||||||
|
if (!formData || formData.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'No se recibió ningún archivo'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar el campo 'avatar'
|
||||||
|
const avatarFile = formData.find(field => field.name === 'avatar')
|
||||||
|
|
||||||
|
if (!avatarFile) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Campo "avatar" no encontrado'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tipo de archivo
|
||||||
|
const contentType = avatarFile.type || ''
|
||||||
|
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `Tipo de archivo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tamaño
|
||||||
|
if (avatarFile.data.length > MAX_FILE_SIZE) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `Archivo muy grande. Máximo: ${MAX_FILE_SIZE / (1024 * 1024)}MB`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear directorio de avatares si no existe
|
||||||
|
const avatarsPath = join(process.cwd(), AVATARS_DIR)
|
||||||
|
try {
|
||||||
|
await fs.access(avatarsPath)
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(avatarsPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar nombre de archivo único basado en username
|
||||||
|
const hash = createHash('md5').update(username).digest('hex').substring(0, 8)
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const extension = contentType.split('/')[1]
|
||||||
|
const filename = `${username}-${hash}-${timestamp}.${extension}`
|
||||||
|
const filepath = join(avatarsPath, filename)
|
||||||
|
|
||||||
|
// Guardar archivo
|
||||||
|
await fs.writeFile(filepath, avatarFile.data)
|
||||||
|
|
||||||
|
// URL pública del avatar
|
||||||
|
const avatarUrl = `/avatars/${filename}`
|
||||||
|
|
||||||
|
// Actualizar Authentik con la URL del avatar
|
||||||
|
const authentikUrl = config.authentikApiUrl || config.public.authentikUrl
|
||||||
|
const authentikToken = config.authentikApiToken
|
||||||
|
|
||||||
|
if (!authentikToken) {
|
||||||
|
console.warn('⚠️ Token de Authentik no configurado, no se puede actualizar metadatos')
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Obtener ID del usuario
|
||||||
|
const usersResponse = await $fetch(`${authentikUrl}/api/v3/core/users/?username=${username}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authentikToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const users = usersResponse as any
|
||||||
|
if (users.results && users.results.length > 0) {
|
||||||
|
const userId = users.results[0].pk
|
||||||
|
|
||||||
|
// Actualizar atributos del usuario
|
||||||
|
await $fetch(`${authentikUrl}/api/v3/core/users/${userId}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authentikToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
attributes: {
|
||||||
|
...users.results[0].attributes,
|
||||||
|
avatar_url: avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`✅ Avatar actualizado para usuario ${username}: ${avatarUrl}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error actualizando Authentik:', err)
|
||||||
|
// No fallar si Authentik no se puede actualizar, el avatar ya está guardado
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpiar avatares antiguos del usuario (opcional)
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(avatarsPath)
|
||||||
|
const userFiles = files.filter(f =>
|
||||||
|
f.startsWith(`${username}-`) &&
|
||||||
|
f !== filename
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const oldFile of userFiles) {
|
||||||
|
await fs.unlink(join(avatarsPath, oldFile))
|
||||||
|
console.log(`🗑️ Eliminado avatar antiguo: ${oldFile}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error limpiando avatares antiguos:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
avatarUrl,
|
||||||
|
message: 'Avatar actualizado correctamente'
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error en upload de avatar:', error)
|
||||||
|
|
||||||
|
// Re-throw si ya es un error de createError
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: error.message || 'Error al subir avatar'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user