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>
|
||||
</div>
|
||||
|
||||
<!-- Avatar URL (deshabilitado) -->
|
||||
<div class="form-field">
|
||||
<!-- Avatar / Foto de perfil -->
|
||||
<div class="form-field full-width">
|
||||
<label class="field-label">
|
||||
<UIcon name="i-heroicons-photo" class="w-4 h-4" />
|
||||
URL del avatar
|
||||
<UIcon name="i-heroicons-camera" class="w-4 h-4" />
|
||||
Foto de perfil
|
||||
</label>
|
||||
<UInput
|
||||
v-model="formData.avatar"
|
||||
placeholder="https://ejemplo.com/avatar.jpg"
|
||||
disabled
|
||||
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
||||
/>
|
||||
<span class="field-help">Próximamente disponible</span>
|
||||
|
||||
<div class="avatar-section">
|
||||
<!-- Preview del avatar actual -->
|
||||
<div class="avatar-preview">
|
||||
<MsnAvatar
|
||||
:src="currentAvatar"
|
||||
:alt="user?.name || user?.username"
|
||||
:presence-status="'online'"
|
||||
:size="120"
|
||||
/>
|
||||
</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>
|
||||
@@ -253,6 +285,25 @@
|
||||
</UButton>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -265,6 +316,8 @@ defineEmits(['close'])
|
||||
|
||||
// Estado del formulario
|
||||
const isSubmitting = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const showCamera = ref(false)
|
||||
|
||||
// Datos del formulario
|
||||
const formData = ref({
|
||||
@@ -279,6 +332,16 @@ const formData = ref({
|
||||
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
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.value.name || !formData.value.email) {
|
||||
@@ -324,6 +387,95 @@ const handleSubmit = async () => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -413,6 +565,50 @@ const handleSubmit = async () => {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -447,6 +643,15 @@ const handleSubmit = async () => {
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -480,4 +685,13 @@ const handleSubmit = async () => {
|
||||
.dark .form-actions {
|
||||
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>
|
||||
|
||||
@@ -23,6 +23,8 @@ interface AuthentikUser {
|
||||
// Metadata de la aplicación y outpost
|
||||
appSlug?: string
|
||||
outpostName?: string
|
||||
// Atributos personalizados
|
||||
attributes?: Record<string, any>
|
||||
}
|
||||
|
||||
interface AuthStatusResponse {
|
||||
@@ -61,8 +63,9 @@ export const useAuthentik = () => {
|
||||
uid,
|
||||
appSlug,
|
||||
outpostName,
|
||||
// Generar avatar URL usando UI Avatars
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(name || username)}&background=random&size=128`
|
||||
// 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`,
|
||||
attributes: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +75,32 @@ export const useAuthentik = () => {
|
||||
const user = computed(() => authentikUser.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 = () => {
|
||||
// Logout completo: invalida la sesión de Authentik completamente
|
||||
// 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