Files
soft_usite/resources/views/profile.html
T
Lizandro GDandClaude Sonnet 5 d2bf699b60 fix: seguridad del webhook de soporte, pagos en contabilidad y Telegram para tareas
- soporte: el webhook de correo entrante era público sin ninguna validación;
  ahora exige una API key (query ?key= o header) comparada en tiempo constante.
  Además evita tickets duplicados por reintentos del proveedor (dedup por
  Message-Id) y enhebra respuestas del mismo remitente en vez de abrir un
  ticket nuevo por cada correo.
- contabilidad: marcar una cuenta por cobrar/pagar como pagada ahora crea y
  vincula la Transaccion correspondiente (antes el dashboard de ingresos/
  egresos nunca reflejaba esos pagos). Se corrige además que actualizar una
  cuenta por cobrar borraba su transaccion_id en cada PUT.
- tareas: se activa por defecto el canal Telegram para tarea_asignada (estaba
  apagado desde el seed original) y se agrega un flujo real de vinculación de
  Telegram para el staff interno (código temporal + verificación), igual al
  que ya existía para los usuarios del portal — sin esto el chat_id de cada
  usuario había que pegarlo a mano y la notificación nunca llegaba.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 01:25:17 +00:00

341 lines
20 KiB
HTML
Executable File

<div x-data="modals" @keydown.escape="closeModal"
class="bg-white rounded-lg shadow max-w-2xl">
<div class="p-6">
<!-- Header -->
<div class="flex items-center gap-4 mb-6 pb-6 border-b border-slate-100">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center text-white text-xl font-bold flex-shrink-0"
style="background: linear-gradient(135deg, #8eb02f 0%, #6d8c24 100%)"
x-data="{n:'{{ .user.NombreUsuario }}'}"
x-text="n ? n.charAt(0).toUpperCase() : 'U'">
</div>
<div>
<h1 class="text-2xl font-bold text-slate-800">Mi perfil</h1>
<p class="text-xs text-slate-500 mt-0.5">Administra tu información personal y seguridad</p>
</div>
</div>
<form @submit.prevent="submitForm" class="space-y-5">
<!-- Nombre -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5" for="name">Nombre completo</label>
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/>
</svg>
</div>
<input type="text" id="name" x-model="Name" placeholder="Ingresa tu nombre completo"
class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none bg-white text-slate-800 transition-all"
style="box-shadow:none"
onfocus="this.style.borderColor='#8eb02f'; this.style.boxShadow='0 0 0 3px rgba(142,176,47,0.15)'"
onblur="this.style.borderColor='#e2e8f0'; this.style.boxShadow='none'"
required>
</div>
</div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5" for="Email">Correo electrónico</label>
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/>
</svg>
</div>
<input id="Email" x-model="Email" placeholder="correo@ejemplo.com" type="email"
class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none bg-white text-slate-800 transition-all"
style="box-shadow:none"
onfocus="this.style.borderColor='#8eb02f'; this.style.boxShadow='0 0 0 3px rgba(142,176,47,0.15)'"
onblur="this.style.borderColor='#e2e8f0'; this.style.boxShadow='none'"
required>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
style="background-color:#8eb02f"
onmouseover="if(!this.disabled) this.style.backgroundColor='#6d8c24'"
onmouseout="if(!this.disabled) this.style.backgroundColor='#8eb02f'"
:disabled="isLoading"
:style="isLoading ? 'opacity:0.7;cursor:not-allowed' : ''">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
<span x-show="!isLoading">Guardar cambios</span>
<span x-show="isLoading">Guardando...</span>
</button>
<button type="button"
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold bg-slate-100 text-slate-700 hover:bg-slate-200 transition-colors"
x-on:click="showPasswordModal = true;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>
</svg>
Cambiar contraseña
</button>
</div>
</form>
<!-- Telegram -->
<div class="mt-6 pt-6 border-t border-slate-100">
<h2 class="text-sm font-semibold text-slate-700 mb-1">Notificaciones por Telegram</h2>
<p class="text-xs text-slate-500 mb-4">Vincula tu Telegram para recibir avisos de tareas asignadas y otras notificaciones personales.</p>
<div x-show="tg.linked" class="flex items-center gap-3 p-3 bg-green-50 border border-green-200 rounded-lg">
<svg class="w-5 h-5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div>
<p class="text-sm font-semibold text-green-800">Telegram vinculado</p>
<p class="text-xs text-green-600">Chat ID: <span class="font-mono" x-text="tg.chat_id"></span></p>
</div>
</div>
<div x-show="!tg.linked" class="space-y-3">
<div class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full text-white flex items-center justify-center text-xs font-bold" style="background-color:#8eb02f">1</span>
<div class="flex-1">
<p class="text-sm text-slate-700 font-medium">Genera tu código</p>
<button @click="tgGenerarCodigo()" :disabled="tg.generando"
class="mt-1.5 inline-flex items-center gap-1.5 px-3 py-1.5 text-white text-xs font-semibold rounded-lg transition-colors disabled:opacity-60"
style="background-color:#8eb02f">
<span x-show="!tg.generando">Generar código</span>
<span x-show="tg.generando">Generando…</span>
</button>
</div>
</div>
<div x-show="tg.token" class="ml-9 p-3 bg-slate-800 rounded-lg">
<p class="text-xs text-slate-400 mb-1">Tu código de vinculación:</p>
<p class="text-2xl font-mono font-bold text-white tracking-widest" x-text="tg.token"></p>
</div>
<div x-show="tg.token" class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full text-white flex items-center justify-center text-xs font-bold" style="background-color:#8eb02f">2</span>
<div class="flex-1">
<p class="text-sm text-slate-700 font-medium">Envía el código al bot</p>
<div class="flex items-center gap-2 p-2 bg-slate-100 rounded-lg mt-1">
<code class="text-sm font-mono text-slate-800" x-text="'/vincular ' + tg.token"></code>
</div>
<template x-if="tg.bot_username">
<a :href="tg.bot_link" target="_blank" class="inline-flex items-center gap-1 mt-2 text-xs font-semibold hover:underline" style="color:#8eb02f">
Abrir @<span x-text="tg.bot_username"></span>
</a>
</template>
</div>
</div>
<div x-show="tg.token" class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full text-white flex items-center justify-center text-xs font-bold" style="background-color:#8eb02f">3</span>
<div class="flex-1">
<p class="text-sm text-slate-700 font-medium">Verifica la vinculación</p>
<button @click="tgVerificar()" :disabled="tg.verificando"
class="mt-1.5 inline-flex items-center gap-1.5 px-3 py-1.5 border text-xs font-semibold rounded-lg transition-colors disabled:opacity-60"
style="border-color:#8eb02f;color:#8eb02f">
<span x-show="!tg.verificando">Verificar vinculación</span>
<span x-show="tg.verificando">Verificando…</span>
</button>
<p x-show="tg.verMsg" x-text="tg.verMsg" :class="tg.verOk ? 'text-green-600' : 'text-red-500'" class="text-xs mt-2"></p>
</div>
</div>
</div>
</div>
</div>
<!-- Modal cambiar contraseña -->
<div x-cloak x-show="showPasswordModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex z-50 items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100">
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background-color:rgba(142,176,47,.1)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" style="color:#8eb02f" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-slate-800">Nueva contraseña</h3>
</div>
<button @click="closeModal" class="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Modal Body -->
<form @submit.prevent="changePass" class="px-6 py-5 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5" for="password">Nueva contraseña</label>
<input type="password" id="password" x-model="password"
placeholder="Mínimo 8 caracteres"
class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm outline-none bg-white transition-all"
style="box-shadow:none"
onfocus="this.style.borderColor='#8eb02f'; this.style.boxShadow='0 0 0 3px rgba(142,176,47,0.15)'"
onblur="this.style.borderColor='#e2e8f0'; this.style.boxShadow='none'"
required>
</div>
<div class="flex gap-3 pt-1">
<button type="submit"
class="flex-1 py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
Guardar
</button>
<button type="button"
class="flex-1 py-2.5 rounded-xl text-sm font-semibold bg-slate-100 text-slate-700 hover:bg-slate-200 transition-colors"
x-on:click="closeModal">
Cancelar
</button>
</div>
</form>
</div>
</div>
<!-- Alpine.js Script -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('modals', () => ({
showPasswordModal: false,
Name: '',
Email: '',
password: '',
Usuario: '',
rolID: '',
Id: null,
isLoading: false,
usuarioID: '{{ .user.ID }}', // Inicializar directamente en el Alpine.js
tg: { linked: false, chat_id: '', token: '', bot_username: '', bot_link: '', generando: false, verificando: false, verMsg: '', verOk: false },
init() {
this.loadRegister();
this.loadTelegramStatus();
},
async loadTelegramStatus() {
try {
const r = await axios.get('/app/profile/telegram-status');
this.tg.linked = r.data.linked;
this.tg.chat_id = r.data.chat_id;
} catch (e) {}
},
async tgGenerarCodigo() {
this.tg.generando = true;
this.tg.token = '';
this.tg.verMsg = '';
try {
const r = await axios.post('/app/profile/telegram-init');
this.tg.token = r.data.token;
this.tg.bot_username = r.data.bot_username || '';
this.tg.bot_link = r.data.bot_link || '';
} catch (e) {
this.tg.verMsg = 'No se pudo generar el código. Intenta de nuevo.';
this.tg.verOk = false;
} finally { this.tg.generando = false; }
},
async tgVerificar() {
this.tg.verificando = true;
this.tg.verMsg = '';
try {
const r = await axios.post('/app/profile/telegram-validar');
if (r.data.ok) {
this.tg.linked = true;
this.tg.chat_id = r.data.chat_id;
this.tg.token = '';
this.tg.verMsg = '✅ ¡Telegram vinculado correctamente!';
this.tg.verOk = true;
} else {
this.tg.verMsg = r.data.error || 'No se encontró el mensaje.';
this.tg.verOk = false;
}
} catch (e) {
this.tg.verMsg = e.response?.data?.error || 'Error al verificar. Intenta de nuevo.';
this.tg.verOk = false;
} finally { this.tg.verificando = false; }
},
loadRegister() {
this.isLoading = true;
fetch(`/app/user/` + this.usuarioID)
.then(response => {
if (!response.ok) throw new Error('Error al cargar el usuario');
return response.json();
})
.then(data => {
this.Name = data.user.name || '';
this.Email = data.user.email || '';
this.Usuario = data.user.nombre_usuario || '';
this.rolID = data.user.role_id || '';
})
.catch(error => alert(`Error: ${error.message}`)) // Manejo de errores
.finally(() => {
this.isLoading = false;
});
},
closeModal() {
this.showPasswordModal = false;
},
submitForm() {
this.isLoading = true;
fetch(`/app/users/` + this.usuarioID, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.Name,
email: this.Email,
nombre_usuario: this.Usuario,
role_id: this.rolID,
})
})
.then(response => {
if (!response.ok) throw new Error('Error al actualizar el usuario');
this.loadRegister();
return response.json();
})
.then(() => {
alert('Usuario actualizado con éxito');
})
.catch(error => alert(`Error: ${error.message}`))
.finally(() => {
this.isLoading = false;
});
},
changePass() {
this.isLoading = true;
fetch(`/app/password/` + this.usuarioID, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
password: this.password,
})
})
.then(response => {
if (!response.ok) throw new Error('Error al cambiar la contraseña');
return response.json();
})
.then(() => {
this.password = '';
this.closeModal();
alert('Contraseña cambiada con éxito');
})
.catch(error => alert(`Error: ${error.message}`))
.finally(() => {
this.isLoading = false;
});
},
}));
});
</script>
</div>