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 @@
+
+
+
+
+
+ {{ permissionMessage }}
+
+
+
+ Activar cámara
+
+
+
+
Solicitando permisos...
+
+
+
+
+
+
+ {{ error }}
+
+
+ {{ errorHelp }}
+
+
+
+ Reintentar
+
+
+
+
+
+
+
+
+
+
+ Cambiar cámara
+
+
+
+
+
+ Capturar foto
+
+
+
+
+
+ Cancelar
+
+
+
+
+
+
+
![Foto capturada]()
+
+
+
+
+
+ Tomar otra
+
+
+
+
+ Usar esta foto
+
+
+
+
+
+
+
+
+
+
+
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
-
-
@@ -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'
+ })
+ }
+})