SPA nueva en /orchestrator (Vue 3 + Vite, servida por el mismo binario Go bajo /orchestrator para que la cookie de sesión funcione sin tocar CORS), reemplaza al panel Alpine.js como punto de entrada del menú. Backend, todo aditivo sobre el motor de uMind ya existente: - UmindHerramienta: tools custom por tenant que llaman un webhook HTTP, integradas al loop de function-calling existente. Cliente HTTP con guardas SSRF (bloqueo de IPs privadas/loopback/link-local resuelto en el momento de conectar, no antes, para cerrar la ventana de DNS rebinding) que no existían en el proyecto. - UmindCanal: Telegram y WhatsApp Business Cloud API como canales adicionales del mismo agente que ya atiende el widget web, ambos reusando ProcessWidgetMessage. WhatsApp valida X-Hub-Signature-256. Credenciales cifradas en reposo con el mismo AES-GCM+APP_KEY que ya usa el proyecto para la contraseña SMTP (primer uso para secretos de uMind). - Se conecta middlewares.Limit() (rate limiter que existía pero no se usaba en ningún lado) al widget público y a los webhooks nuevos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
215 lines
6.3 KiB
Vue
215 lines
6.3 KiB
Vue
<script setup>
|
|
import { onMounted, ref } from 'vue'
|
|
import { api } from '../lib/api.js'
|
|
|
|
const tenants = ref([])
|
|
const aiConfigs = ref([])
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const showForm = ref(false)
|
|
const editing = ref(null)
|
|
|
|
const form = ref(vacio())
|
|
|
|
function vacio() {
|
|
return {
|
|
nombre: '',
|
|
dominios_permitidos: '',
|
|
ai_config_id: null,
|
|
tono: '',
|
|
mensaje_bienvenida: '',
|
|
activo: true,
|
|
}
|
|
}
|
|
|
|
async function cargar() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const [t, ai] = await Promise.all([
|
|
api.get('/app/umind/tenants'),
|
|
api.get('/app/api/ai-config/select'),
|
|
])
|
|
tenants.value = t.items || []
|
|
aiConfigs.value = ai.registros || []
|
|
} catch (e) {
|
|
error.value = e.message
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function nuevoTenant() {
|
|
editing.value = null
|
|
form.value = vacio()
|
|
showForm.value = true
|
|
}
|
|
|
|
function editarTenant(t) {
|
|
editing.value = t
|
|
form.value = {
|
|
nombre: t.nombre,
|
|
dominios_permitidos: t.dominios_permitidos,
|
|
ai_config_id: t.ai_config_id,
|
|
tono: t.tono,
|
|
mensaje_bienvenida: t.mensaje_bienvenida,
|
|
activo: t.activo,
|
|
}
|
|
showForm.value = true
|
|
}
|
|
|
|
async function guardar() {
|
|
const payload = {
|
|
...form.value,
|
|
dominios_permitidos: form.value.dominios_permitidos
|
|
.split(',')
|
|
.map((d) => d.trim())
|
|
.filter(Boolean),
|
|
}
|
|
try {
|
|
if (editing.value) {
|
|
await api.put(`/app/umind/tenants/${editing.value.ID}`, payload)
|
|
} else {
|
|
await api.post('/app/umind/tenants', payload)
|
|
}
|
|
showForm.value = false
|
|
await cargar()
|
|
} catch (e) {
|
|
error.value = e.message
|
|
}
|
|
}
|
|
|
|
async function eliminar(t) {
|
|
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto no se puede deshacer.`)) return
|
|
try {
|
|
await api.del(`/app/umind/tenants/${t.ID}`)
|
|
await cargar()
|
|
} catch (e) {
|
|
error.value = e.message
|
|
}
|
|
}
|
|
|
|
onMounted(cargar)
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-xl font-semibold text-gray-800">Tenants</h1>
|
|
<button
|
|
class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg"
|
|
@click="nuevoTenant"
|
|
>
|
|
+ Nuevo tenant
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="error" class="text-sm text-red-600 mb-4">{{ error }}</p>
|
|
<p v-if="loading" class="text-sm text-gray-500">Cargando...</p>
|
|
|
|
<div v-else class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
|
<div v-if="tenants.length === 0" class="p-6 text-sm text-gray-500">
|
|
Todavía no hay tenants. Creá el primero.
|
|
</div>
|
|
<div
|
|
v-for="t in tenants"
|
|
:key="t.ID"
|
|
class="p-4 flex items-center justify-between hover:bg-gray-50"
|
|
>
|
|
<div>
|
|
<router-link
|
|
:to="`/tenants/${t.ID}`"
|
|
class="font-medium text-gray-800 hover:text-brand"
|
|
>
|
|
{{ t.nombre }}
|
|
</router-link>
|
|
<div class="text-xs text-gray-500 mt-0.5">
|
|
{{ t.dominios_permitidos || 'sin dominios configurados' }}
|
|
<span
|
|
class="ml-2 px-1.5 py-0.5 rounded"
|
|
:class="t.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
|
>
|
|
{{ t.activo ? 'activo' : 'inactivo' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 text-sm">
|
|
<button class="text-gray-500 hover:text-gray-800" @click="editarTenant(t)">Editar</button>
|
|
<button class="text-red-500 hover:text-red-700" @click="eliminar(t)">Eliminar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal simple de alta/edición -->
|
|
<div
|
|
v-if="showForm"
|
|
class="fixed inset-0 bg-black/30 flex items-center justify-center p-4 z-50"
|
|
@click.self="showForm = false"
|
|
>
|
|
<div class="bg-white rounded-xl p-6 w-full max-w-lg">
|
|
<h2 class="font-semibold text-gray-800 mb-4">
|
|
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
|
|
</h2>
|
|
<form class="space-y-3" @submit.prevent="guardar">
|
|
<div>
|
|
<label class="text-xs text-gray-500">Nombre</label>
|
|
<input
|
|
v-model="form.nombre"
|
|
required
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-gray-500">Dominios permitidos (separados por coma)</label>
|
|
<input
|
|
v-model="form.dominios_permitidos"
|
|
placeholder="ejemplo.com, www.ejemplo.com"
|
|
required
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-gray-500">Config de IA</label>
|
|
<select
|
|
v-model="form.ai_config_id"
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
|
>
|
|
<option :value="null">— sin asignar —</option>
|
|
<option v-for="c in aiConfigs" :key="c.ID" :value="c.ID">
|
|
{{ c.nombre }} ({{ c.provider }})
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-gray-500">Tono / personalidad</label>
|
|
<textarea
|
|
v-model="form.tono"
|
|
rows="2"
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
|
></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-gray-500">Mensaje de bienvenida</label>
|
|
<input
|
|
v-model="form.mensaje_bienvenida"
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<label class="flex items-center gap-2 text-sm text-gray-600">
|
|
<input v-model="form.activo" type="checkbox" />
|
|
Activo
|
|
</label>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button type="button" class="px-4 py-2 text-sm text-gray-500" @click="showForm = false">
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">
|
|
Guardar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|