All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 55s
- Mover sección de foto de perfil al primer lugar del formulario - Eliminar preview del avatar (MsnAvatar) - no repetir - Simplificar botones: más compactos (size="sm", iconos w-4 h-4) - Eliminar card/container extra (.avatar-section) con background y padding - Nueva clase simple .avatar-actions-simple con solo flex y gap - Limpiar estilos CSS no utilizados (avatar-section, avatar-preview, avatar-actions)
654 lines
16 KiB
Vue
654 lines
16 KiB
Vue
<template>
|
|
<div class="profile-form-container">
|
|
<div class="form-header">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="form-title">
|
|
<UIcon name="i-heroicons-user-circle" class="w-6 h-6" />
|
|
Editar Perfil
|
|
</h2>
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-heroicons-x-mark"
|
|
@click="$emit('close')"
|
|
>
|
|
Cancelar
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<form @submit.prevent="handleSubmit" class="form-content">
|
|
<!-- Sección: Foto de Perfil -->
|
|
<div class="form-section">
|
|
<h3 class="section-title">
|
|
<UIcon name="i-heroicons-camera" class="w-5 h-5" />
|
|
Foto de Perfil
|
|
</h3>
|
|
<div class="avatar-actions-simple">
|
|
<UButton
|
|
@click="showCamera = true"
|
|
color="primary"
|
|
size="sm"
|
|
:disabled="isUploading"
|
|
>
|
|
<UIcon name="i-heroicons-camera" class="w-4 h-4" />
|
|
{{ currentAvatar && currentAvatar.includes('/avatars/') ? 'Cambiar foto' : 'Tomar foto' }}
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="currentAvatar && currentAvatar.includes('/avatars/')"
|
|
@click="removeAvatar"
|
|
color="neutral"
|
|
variant="soft"
|
|
size="sm"
|
|
:disabled="isUploading"
|
|
>
|
|
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
|
Eliminar
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sección: Información Básica -->
|
|
<div class="form-section">
|
|
<h3 class="section-title">
|
|
<UIcon name="i-heroicons-identification" class="w-5 h-5" />
|
|
Información Básica
|
|
</h3>
|
|
<div class="form-grid">
|
|
<!-- Nombre de usuario (readonly) -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-at-symbol" class="w-4 h-4" />
|
|
Nombre de usuario
|
|
</label>
|
|
<UInput
|
|
:model-value="user?.username"
|
|
disabled
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">No se puede cambiar</span>
|
|
</div>
|
|
|
|
<!-- UID de Authentik (readonly) -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-finger-print" class="w-4 h-4" />
|
|
UID de Authentik
|
|
</label>
|
|
<UInput
|
|
:model-value="user?.uid || 'No disponible'"
|
|
disabled
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">Identificador único del sistema</span>
|
|
</div>
|
|
|
|
<!-- Nombre completo -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-user" class="w-4 h-4" />
|
|
Nombre completo
|
|
<span class="required">*</span>
|
|
</label>
|
|
<UInput
|
|
v-model="formData.name"
|
|
placeholder="Tu nombre completo"
|
|
:disabled="isSubmitting"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Email -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-envelope" class="w-4 h-4" />
|
|
Correo electrónico
|
|
<span class="required">*</span>
|
|
</label>
|
|
<UInput
|
|
v-model="formData.email"
|
|
type="email"
|
|
placeholder="tu@email.com"
|
|
:disabled="isSubmitting"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sección: Conectividad -->
|
|
<div class="form-section">
|
|
<h3 class="section-title">
|
|
<UIcon name="i-heroicons-wifi" class="w-5 h-5" />
|
|
Conectividad
|
|
</h3>
|
|
<div class="form-grid">
|
|
<!-- Contraseña WiFi (readonly) -->
|
|
<div class="form-field full-width">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-key" class="w-4 h-4" />
|
|
Contraseña WiFi Nucleo
|
|
</label>
|
|
<UInput
|
|
value="Nucleo2024WiFi!"
|
|
disabled
|
|
type="text"
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">Contraseña de la red WiFi de Nucleo</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sección: Información Personal -->
|
|
<div class="form-section">
|
|
<h3 class="section-title">
|
|
<UIcon name="i-heroicons-user-circle" class="w-5 h-5" />
|
|
Información Personal
|
|
</h3>
|
|
<div class="form-grid">
|
|
<!-- Teléfono (deshabilitado) -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-phone" class="w-4 h-4" />
|
|
Teléfono
|
|
</label>
|
|
<UInput
|
|
v-model="formData.phone"
|
|
placeholder="+506 1234-5678"
|
|
disabled
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">Próximamente disponible</span>
|
|
</div>
|
|
|
|
<!-- Cédula (deshabilitado) -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-identification" class="w-4 h-4" />
|
|
Cédula
|
|
</label>
|
|
<UInput
|
|
v-model="formData.cedula"
|
|
placeholder="1-2345-6789"
|
|
disabled
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">Próximamente disponible</span>
|
|
</div>
|
|
|
|
<!-- Fecha de nacimiento (deshabilitado) -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-cake" class="w-4 h-4" />
|
|
Fecha de nacimiento
|
|
</label>
|
|
<UInput
|
|
v-model="formData.birthdate"
|
|
type="date"
|
|
disabled
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">Próximamente disponible</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sección: Seguridad y Acceso -->
|
|
<div class="form-section">
|
|
<h3 class="section-title">
|
|
<UIcon name="i-heroicons-shield-check" class="w-5 h-5" />
|
|
Seguridad y Acceso
|
|
</h3>
|
|
<div class="form-grid">
|
|
<!-- NFC vinculada (deshabilitado) -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-credit-card" class="w-4 h-4" />
|
|
NFC vinculada
|
|
</label>
|
|
<UInput
|
|
v-model="formData.nfc"
|
|
placeholder="ID de tarjeta NFC"
|
|
disabled
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">Próximamente disponible</span>
|
|
</div>
|
|
|
|
<!-- PIN numérico (deshabilitado) -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-lock-closed" class="w-4 h-4" />
|
|
PIN numérico
|
|
</label>
|
|
<UInput
|
|
v-model="formData.pin"
|
|
type="password"
|
|
placeholder="••••"
|
|
disabled
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">Próximamente disponible</span>
|
|
</div>
|
|
|
|
<!-- Código Nucleo V2 (deshabilitado) -->
|
|
<div class="form-field">
|
|
<label class="field-label">
|
|
<UIcon name="i-heroicons-qr-code" class="w-4 h-4" />
|
|
Código Nucleo V2
|
|
</label>
|
|
<UInput
|
|
v-model="formData.nucleoCode"
|
|
placeholder="NUCLEO-XXXX-XXXX"
|
|
disabled
|
|
:ui="{ base: 'cursor-not-allowed opacity-50' }"
|
|
/>
|
|
<span class="field-help">Próximamente disponible</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Botones de acción -->
|
|
<div class="form-actions">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="lg"
|
|
@click="$emit('close')"
|
|
:disabled="isSubmitting"
|
|
>
|
|
Cancelar
|
|
</UButton>
|
|
<UButton
|
|
color="primary"
|
|
size="lg"
|
|
type="submit"
|
|
:loading="isSubmitting"
|
|
>
|
|
Guardar cambios
|
|
</UButton>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Modal de cámara -->
|
|
<UModal v-model:open="showCamera">
|
|
<template #content>
|
|
<div class="camera-modal max-w-3xl mx-auto">
|
|
<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>
|
|
</template>
|
|
</UModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
const { user } = useAuthentik()
|
|
const toast = useToast()
|
|
|
|
// Emits
|
|
defineEmits(['close'])
|
|
|
|
// Estado del formulario
|
|
const isSubmitting = ref(false)
|
|
const isUploading = ref(false)
|
|
const showCamera = ref(false)
|
|
|
|
// Datos del formulario
|
|
const formData = ref({
|
|
name: user.value?.name || '',
|
|
email: user.value?.email || '',
|
|
avatar: '',
|
|
phone: '',
|
|
cedula: '',
|
|
birthdate: '',
|
|
nfc: '',
|
|
pin: '',
|
|
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) {
|
|
toast.add({
|
|
title: 'Error',
|
|
description: 'Por favor completa todos los campos requeridos',
|
|
color: 'error',
|
|
icon: 'i-heroicons-exclamation-triangle'
|
|
})
|
|
return
|
|
}
|
|
|
|
isSubmitting.value = true
|
|
|
|
try {
|
|
await $fetch('/api/authentik/user', {
|
|
method: 'PATCH',
|
|
body: {
|
|
name: formData.value.name,
|
|
email: formData.value.email
|
|
}
|
|
})
|
|
|
|
toast.add({
|
|
title: 'Perfil actualizado',
|
|
description: 'Tus cambios se guardaron correctamente. Recarga la página para verlos.',
|
|
color: 'success',
|
|
icon: 'i-heroicons-check-circle',
|
|
actions: [{
|
|
label: 'Recargar',
|
|
onClick: () => window.location.reload()
|
|
}]
|
|
})
|
|
} catch (error) {
|
|
console.error('Error updating profile:', error)
|
|
toast.add({
|
|
title: 'Error',
|
|
description: 'No se pudo actualizar el perfil',
|
|
color: 'error',
|
|
icon: 'i-heroicons-x-circle'
|
|
})
|
|
} finally {
|
|
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>
|
|
.profile-form-container {
|
|
background: rgba(255, 255, 255, 0.35);
|
|
backdrop-filter: blur(20px) saturate(180%);
|
|
border-radius: 1.5rem;
|
|
padding: 2rem;
|
|
box-shadow:
|
|
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
|
inset 0 1px 1px 0 rgba(255, 255, 255, 0.3);
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.form-header {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.form-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
font-size: 1.75rem;
|
|
font-weight: 700;
|
|
color: var(--color-gray-900);
|
|
margin: 0;
|
|
}
|
|
|
|
.form-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2rem;
|
|
}
|
|
|
|
.form-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.section-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
color: var(--color-gray-800);
|
|
margin: 0 0 0.5rem 0;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 2px solid rgba(var(--color-primary-500), 0.2);
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.form-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.form-field.full-width {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.field-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--color-gray-700);
|
|
}
|
|
|
|
.field-label .required {
|
|
color: rgb(var(--color-error-500));
|
|
margin-left: 0.125rem;
|
|
}
|
|
|
|
.field-help {
|
|
font-size: 0.75rem;
|
|
color: var(--color-gray-500);
|
|
font-style: italic;
|
|
}
|
|
|
|
.avatar-actions-simple {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.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;
|
|
gap: 1rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.profile-form-container {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.form-title {
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.form-grid {
|
|
grid-template-columns: 1fr;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.form-field.full-width {
|
|
grid-column: 1;
|
|
}
|
|
|
|
.form-actions {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* Estilos de modo oscuro (sin scoped para que .dark funcione correctamente) */
|
|
.dark .profile-form-container {
|
|
background: rgba(0, 0, 0, 0.15) !important;
|
|
box-shadow:
|
|
0 8px 32px 0 rgba(0, 0, 0, 0.5),
|
|
inset 0 1px 1px 0 rgba(255, 255, 255, 0.05) !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
|
}
|
|
|
|
.dark .form-title {
|
|
color: var(--color-gray-100) !important;
|
|
}
|
|
|
|
.dark .section-title {
|
|
color: var(--color-gray-100) !important;
|
|
border-bottom-color: rgba(var(--color-primary-500), 0.3) !important;
|
|
}
|
|
|
|
.dark .field-label {
|
|
color: var(--color-gray-300) !important;
|
|
}
|
|
|
|
.dark .field-help {
|
|
color: var(--color-gray-400) !important;
|
|
}
|
|
|
|
.dark .form-actions {
|
|
border-top-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.dark .modal-title {
|
|
color: var(--color-gray-100) !important;
|
|
}
|
|
</style>
|