diff --git a/nuxt4/app/components/CameraCapture.vue b/nuxt4/app/components/CameraCapture.vue new file mode 100644 index 0000000..8f066aa --- /dev/null +++ b/nuxt4/app/components/CameraCapture.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/nuxt4/app/components/UserProfileForm.vue b/nuxt4/app/components/UserProfileForm.vue index 2c3c3ec..67df3af 100644 --- a/nuxt4/app/components/UserProfileForm.vue +++ b/nuxt4/app/components/UserProfileForm.vue @@ -160,19 +160,51 @@ Próximamente disponible - -
+ +
- - Próximamente disponible + +
+ +
+ +
+ + +
+ + + {{ currentAvatar ? 'Cambiar foto' : 'Tomar foto' }} + + + + + Eliminar + +
+
+ + + Usa la cámara para tomar una foto de perfil personalizada +
@@ -253,6 +285,25 @@ + + + +
+ + + +
+
@@ -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 + } +} @@ -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; +} diff --git a/nuxt4/app/composables/useAuthentik.ts b/nuxt4/app/composables/useAuthentik.ts index 00e079f..17cd7b4 100644 --- a/nuxt4/app/composables/useAuthentik.ts +++ b/nuxt4/app/composables/useAuthentik.ts @@ -23,6 +23,8 @@ interface AuthentikUser { // Metadata de la aplicación y outpost appSlug?: string outpostName?: string + // Atributos personalizados + attributes?: Record } 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('/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 diff --git a/nuxt4/public/avatars/.gitkeep b/nuxt4/public/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/nuxt4/server/api/avatar/upload.post.ts b/nuxt4/server/api/avatar/upload.post.ts new file mode 100644 index 0000000..97a4311 --- /dev/null +++ b/nuxt4/server/api/avatar/upload.post.ts @@ -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' + }) + } +})