feat(umind): el cliente administra sus agentes desde el portal

Acá es donde uMind deja de ser una herramienta interna: el cliente entra
a /portal/studio con su sesión de portal y gestiona lo suyo.

- UmindScopePortal/UmindScopeStaff es el ÚNICO punto donde se decide el
  alcance. El del cliente sale de GetClienteIDsForPortalUser, el mismo
  que ya autoriza el resto del portal. nil = staff sin restricción,
  slice vacío = no ve nada; una ruta sin scope también cae en "no ve
  nada" para que olvidarse el middleware falle visible y no abra todo.
- Un solo set de handlers montado bajo /app/umind y /portal/umind
  (RegistrarRutasUmind). Duplicarlos sería duplicar las chances de
  olvidar un chequeo.
- Guarda de acceso en TODOS los handlers, incluidos los sub-recursos que
  llegan por :id (documento, tool, canal, conexión): hay que cargarlos
  para saber de quién son, si no un cliente podría borrar el canal de
  otro adivinando el id. Responden 404, no 403: un 403 confirmaría que
  el recurso existe.
- Cierra un bug preexistente: las lecturas GET /app/umind/* no tenían
  SoloAdmin ni pasaban por MenuMiddleware, así que cualquier usuario de
  staff podía leer los tenants de todos los clientes.
- Límite de agentes por plan (409 con mensaje claro). Un tenant sin plan
  no tiene límite: cortarles de golpe sería peor que dejarlos como estaban.
- /umind/ai-configs reemplaza con alcance a /app/api/ai-config/select,
  que devolvía TODAS las configs del sistema.
- El SPA deduce por la URL si es staff o cliente (base del router,
  prefijo de API y URL de login) y oculta lo que es solo de staff.
- Test de aislamiento entre clientes: 7 casos, incluido que un scope
  vacío no se confunda con staff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 11:51:12 -05:00
co-authored by Claude Sonnet 5
parent 08265510ea
commit 5b78f6677c
19 changed files with 575 additions and 107 deletions
+22 -21
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
const props = defineProps({
tenantId: { type: String, required: true },
@@ -21,12 +22,12 @@ const maxPaginas = ref(30)
const ingestando = ref(false)
async function cargarAgente() {
const r = await api.get(`/app/umind/agentes?tenant_id=${props.tenantId}`)
const r = await api.get(apiUmind(`/umind/agentes?tenant_id=${props.tenantId}`))
agente.value = (r.items || []).find((x) => String(x.ID) === props.agenteId) || null
}
async function cargarDocumentos() {
const r = await api.get(`/app/umind/documentos?agente_id=${props.agenteId}`)
const r = await api.get(apiUmind(`/umind/documentos?agente_id=${props.agenteId}`))
documentos.value = r.items || []
}
@@ -35,7 +36,7 @@ async function agregarFuente() {
ingestando.value = true
error.value = ''
try {
await api.post('/app/umind/documentos', {
await api.post(apiUmind('/umind/documentos'), {
agente_id: agenteIdNum.value,
url: nuevaUrl.value.trim(),
max_paginas: Number(maxPaginas.value) || 30,
@@ -51,7 +52,7 @@ async function agregarFuente() {
async function eliminarDocumento(id) {
if (!confirm('¿Eliminar esta fuente y sus fragmentos indexados?')) return
await api.del(`/app/umind/documentos/${id}`)
await api.del(apiUmind(`/umind/documentos/${id}`))
await cargarDocumentos()
}
@@ -68,13 +69,13 @@ const historial = ref([])
const sesionActiva = ref(null)
async function cargarSesiones() {
const r = await api.get(`/app/umind/sesiones?agente_id=${props.agenteId}`)
const r = await api.get(apiUmind(`/umind/sesiones?agente_id=${props.agenteId}`))
sesiones.value = r.items || []
}
async function verHistorial(sessionId) {
sesionActiva.value = sessionId
const r = await api.get(`/app/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`)
const r = await api.get(apiUmind(`/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`))
historial.value = r.items || []
}
@@ -92,7 +93,7 @@ function toolVacio() {
}
async function cargarTools() {
const r = await api.get(`/app/umind/tools?agente_id=${props.agenteId}`)
const r = await api.get(apiUmind(`/umind/tools?agente_id=${props.agenteId}`))
tools.value = r.items || []
}
@@ -141,9 +142,9 @@ async function guardarTool() {
}
try {
if (editingTool.value) {
await api.put(`/app/umind/tools/${editingTool.value.ID}`, payload)
await api.put(apiUmind(`/umind/tools/${editingTool.value.ID}`), payload)
} else {
await api.post('/app/umind/tools', payload)
await api.post(apiUmind('/umind/tools'), payload)
}
showToolForm.value = false
await cargarTools()
@@ -154,7 +155,7 @@ async function guardarTool() {
async function eliminarTool(t) {
if (!confirm(`¿Eliminar la tool "${t.nombre}"?`)) return
await api.del(`/app/umind/tools/${t.ID}`)
await api.del(apiUmind(`/umind/tools/${t.ID}`))
await cargarTools()
}
@@ -187,7 +188,7 @@ function canalVacio() {
}
async function cargarCanales() {
const r = await api.get(`/app/umind/canales?agente_id=${props.agenteId}`)
const r = await api.get(apiUmind(`/umind/canales?agente_id=${props.agenteId}`))
canales.value = r.items || []
}
@@ -207,7 +208,7 @@ async function guardarCanal() {
verify_token: canalForm.value.verify_token,
}
try {
await api.post('/app/umind/canales', {
await api.post(apiUmind('/umind/canales'), {
agente_id: agenteIdNum.value, tipo: canalForm.value.tipo, credenciales, activo: true,
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
})
@@ -219,7 +220,7 @@ async function guardarCanal() {
}
async function toggleCanal(c) {
await api.put(`/app/umind/canales/${c.ID}`, {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: !c.activo, credenciales: {},
usar_whisper_audio: c.usar_whisper_audio, usar_ocr_imagenes: c.usar_ocr_imagenes,
})
@@ -227,7 +228,7 @@ async function toggleCanal(c) {
}
async function toggleCanalWhisper(c) {
await api.put(`/app/umind/canales/${c.ID}`, {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: c.activo, credenciales: {},
usar_whisper_audio: !c.usar_whisper_audio, usar_ocr_imagenes: c.usar_ocr_imagenes,
})
@@ -235,7 +236,7 @@ async function toggleCanalWhisper(c) {
}
async function toggleCanalOcr(c) {
await api.put(`/app/umind/canales/${c.ID}`, {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: c.activo, credenciales: {},
usar_whisper_audio: c.usar_whisper_audio, usar_ocr_imagenes: !c.usar_ocr_imagenes,
})
@@ -244,7 +245,7 @@ async function toggleCanalOcr(c) {
async function eliminarCanal(c) {
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
await api.del(`/app/umind/canales/${c.ID}`)
await api.del(apiUmind(`/umind/canales/${c.ID}`))
await cargarCanales()
}
@@ -252,18 +253,18 @@ async function eliminarCanal(c) {
const conexiones = ref([])
async function cargarConexiones() {
const r = await api.get(`/app/umind/conexiones?agente_id=${props.agenteId}`)
const r = await api.get(apiUmind(`/umind/conexiones?agente_id=${props.agenteId}`))
conexiones.value = r.items || []
}
function conectar(proveedor) {
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
window.location.href = `/app/umind/conexiones/conectar?agente_id=${agenteIdNum.value}&proveedor=${proveedor}`
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${agenteIdNum.value}&proveedor=${proveedor}`)
}
async function desconectar(c) {
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
await api.del(`/app/umind/conexiones/${c.ID}`)
await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
await cargarConexiones()
}
@@ -280,7 +281,7 @@ async function enviarChatPrueba() {
chatMensajes.value.push({ role: 'user', content: texto })
chatEnviando.value = true
try {
const r = await api.post('/app/umind/chat', { agente_id: agenteIdNum.value, session_id: chatSessionId, mensaje: texto })
const r = await api.post(apiUmind('/umind/chat'), { agente_id: agenteIdNum.value, session_id: chatSessionId, mensaje: texto })
chatMensajes.value.push({ role: 'assistant', content: r.respuesta })
} catch (e) {
chatMensajes.value.push({ role: 'assistant', content: `⚠️ ${e.message}` })
@@ -303,7 +304,7 @@ const tabs = [
const eventos = ref([])
async function cargarEventos() {
const r = await api.get(`/app/umind/eventos?agente_id=${props.agenteId}`)
const r = await api.get(apiUmind(`/umind/eventos?agente_id=${props.agenteId}`))
eventos.value = r.items || []
}
+12 -2
View File
@@ -1,7 +1,17 @@
<script setup>
import { contexto } from '../lib/contexto.js'
</script>
<template>
<div class="flex flex-col items-center justify-center text-center py-24">
<div class="text-4xl mb-4">💬</div>
<h1 class="text-lg font-medium text-gray-700 dark:text-gray-200">Elegí un tenant de la izquierda</h1>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">o creá uno nuevo para empezar a configurar su agente.</p>
<h1 class="text-lg font-medium text-gray-700 dark:text-gray-200">
{{ contexto.esPortal ? 'Elegí tu espacio de la izquierda' : 'Elegí un tenant de la izquierda' }}
</h1>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
{{ contexto.esPortal
? 'Adentro vas a poder crear y configurar tus agentes.'
: 'o creá uno nuevo para empezar a configurar su agente.' }}
</p>
</div>
</template>
+8 -7
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
const props = defineProps({ id: { type: String, required: true } })
const tenantId = computed(() => Number(props.id))
@@ -23,13 +24,13 @@ async function cargar() {
error.value = ''
try {
const [t, a, ai] = await Promise.all([
api.get('/app/umind/tenants'),
api.get(`/app/umind/agentes?tenant_id=${props.id}`),
api.get('/app/api/ai-config/select'),
api.get(apiUmind('/umind/tenants')),
api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`)),
api.get(apiUmind('/umind/ai-configs')),
])
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
agentes.value = a.items || []
aiConfigs.value = ai.registros || []
aiConfigs.value = ai.items || []
} catch (e) {
error.value = e.message
}
@@ -57,11 +58,11 @@ function editarAgente(a) {
async function guardar() {
try {
if (editing.value) {
await api.put(`/app/umind/agentes/${editing.value.ID}`, { tenant_id: tenantId.value, ...form.value })
await api.put(apiUmind(`/umind/agentes/${editing.value.ID}`), { tenant_id: tenantId.value, ...form.value })
showForm.value = false
await cargar()
} else {
const r = await api.post('/app/umind/agentes', { tenant_id: tenantId.value, ...form.value })
const r = await api.post(apiUmind('/umind/agentes'), { tenant_id: tenantId.value, ...form.value })
showForm.value = false
router.push(`/tenants/${tenantId.value}/agentes/${r.id}`)
}
@@ -72,7 +73,7 @@ async function guardar() {
async function eliminarAgente(a) {
if (!confirm(`¿Eliminar el agente "${a.nombre}"? Esto no se puede deshacer.`)) return
await api.del(`/app/umind/agentes/${a.ID}`)
await api.del(apiUmind(`/umind/agentes/${a.ID}`))
await cargar()
}