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
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind — Orquestador</title>
<title>uMind Studio</title>
</head>
<body class="bg-gray-50">
<div id="app"></div>
+12 -8
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
const route = useRoute()
const router = useRouter()
@@ -27,7 +28,7 @@ async function cargar() {
loading.value = true
error.value = ''
try {
const t = await api.get('/app/umind/tenants')
const t = await api.get(apiUmind('/umind/tenants'))
tenants.value = t.items || []
} catch (e) {
error.value = e.message
@@ -39,6 +40,7 @@ async function cargar() {
// Clientes y planes solo los necesita el staff para asignarlos; si el endpoint
// no está disponible (portal del cliente) el formulario sigue funcionando.
async function cargarAsignables() {
if (contexto.esPortal) return // endpoints de staff: el cliente no los alcanza
try {
const [c, p] = await Promise.all([
api.get('/app/api/clientes/select'),
@@ -80,11 +82,11 @@ async function guardar() {
}
try {
if (editing.value) {
await api.put(`/app/umind/tenants/${editing.value.ID}`, payload)
await api.put(apiUmind(`/umind/tenants/${editing.value.ID}`), payload)
showForm.value = false
await cargar()
} else {
const r = await api.post('/app/umind/tenants', payload)
const r = await api.post(apiUmind('/umind/tenants'), payload)
showForm.value = false
await cargar()
router.push(`/tenants/${r.id}`)
@@ -96,7 +98,7 @@ async function guardar() {
async function eliminar(t) {
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
await api.del(`/app/umind/tenants/${t.ID}`)
await api.del(apiUmind(`/umind/tenants/${t.ID}`))
if (tenantActivoId.value === String(t.ID)) router.push('/')
await cargar()
}
@@ -112,11 +114,11 @@ onMounted(() => {
<aside class="w-64 shrink-0 h-screen sticky top-0 flex flex-col border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
<div class="px-4 py-4 border-b border-gray-200 dark:border-gray-800">
<router-link to="/" class="text-base font-semibold text-gray-800 dark:text-gray-100">
uMind <span class="text-brand">Orquestador</span>
uMind <span class="text-brand">Studio</span>
</router-link>
</div>
<div class="px-3 pt-3">
<div v-if="!contexto.esPortal" class="px-3 pt-3">
<button
class="w-full bg-brand hover:bg-brand-dark text-white text-sm font-medium px-3 py-2 rounded-lg transition-colors"
@click="nuevoTenant"
@@ -129,7 +131,9 @@ onMounted(() => {
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
<p v-if="loading" class="px-2 text-xs text-gray-400">Cargando...</p>
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-gray-400">Sin tenants todavía.</p>
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-gray-400">
{{ contexto.esPortal ? 'Todavía no tenés ningún espacio asignado. Escribinos y lo activamos.' : 'Sin tenants todavía.' }}
</p>
<div
v-for="t in tenants"
:key="t.ID"
@@ -147,7 +151,7 @@ onMounted(() => {
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ t.activo ? 'activo' : 'inactivo' }}</span>
</div>
</router-link>
<div class="flex opacity-0 group-hover:opacity-100 transition-opacity pr-1.5 gap-0.5">
<div v-if="!contexto.esPortal" class="flex opacity-0 group-hover:opacity-100 transition-opacity pr-1.5 gap-0.5">
<button
class="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
title="Editar"
+12 -6
View File
@@ -1,7 +1,9 @@
// Wrapper de fetch para las rutas de sesión /app/umind/*. La cookie de
// sesión viaja sola por ser mismo origen. Si el JWT expiró, AuthWeb()
// redirige a /login devolviendo HTML en vez de un 401 JSON — fetch sigue
// ese redirect solo, así que lo detectamos por el content-type de vuelta.
import { contexto } from './contexto.js'
// Wrapper de fetch para las rutas de sesión de uMind. La cookie de sesión
// viaja sola por ser mismo origen. Si la sesión expiró, tanto AuthWeb() como
// PortalAuth() redirigen al login devolviendo HTML en vez de un 401 JSON —
// fetch sigue ese redirect solo, así que lo detectamos por el content-type.
async function request(path, options = {}) {
const res = await fetch(path, {
...options,
@@ -10,13 +12,17 @@ async function request(path, options = {}) {
const contentType = res.headers.get('content-type') || ''
if (res.redirected || !contentType.includes('application/json')) {
window.location.href = '/login'
window.location.href = contexto.urlLogin
throw new Error('Sesión expirada')
}
const data = await res.json()
if (!res.ok) {
throw new Error(data?.error || data?.message || 'Error de servidor')
// El backend responde {"error": "..."} pero algunos handlers viejos usan
// {"error": true, "message": "..."} — sin este chequeo el usuario veía
// literalmente "true" como mensaje de error.
const msg = typeof data?.error === 'string' ? data.error : data?.message
throw new Error(msg || 'Error de servidor')
}
return data
}
+21
View File
@@ -0,0 +1,21 @@
// El mismo build se sirve en dos lugares: /orchestrator (staff, sesión de
// panel) y /portal/studio (cliente, sesión de portal). Ambos exponen los
// mismos endpoints de uMind pero bajo distinto prefijo y con distinto
// alcance, así que la app deduce dónde está parada mirando la URL.
const enPortal = window.location.pathname.startsWith('/portal/')
export const contexto = {
esPortal: enPortal,
// Base del router (vue-router en modo history).
baseRuta: enPortal ? '/portal/studio/' : '/orchestrator/',
// Prefijo de la API de uMind.
apiBase: enPortal ? '/portal' : '/app',
// A dónde mandar al usuario cuando se le venció la sesión.
urlLogin: enPortal ? '/portal/login' : '/login',
}
// apiUmind arma la ruta de un endpoint de uMind para el contexto actual:
// apiUmind('/umind/agentes') → '/app/umind/agentes' o '/portal/umind/agentes'
export function apiUmind(path) {
return contexto.apiBase + path
}
+2 -1
View File
@@ -2,9 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
import Home from './views/Home.vue'
import TenantAgentes from './views/TenantAgentes.vue'
import AgenteDetail from './views/AgenteDetail.vue'
import { contexto } from './lib/contexto.js'
const router = createRouter({
history: createWebHistory('/orchestrator/'),
history: createWebHistory(contexto.baseRuta),
routes: [
{ path: '/', name: 'home', component: Home },
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
+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()
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind — Orquestador</title>
<script type="module" crossorigin src="/orchestrator/assets/index-BfxLbsxs.js"></script>
<title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-DPluLgCZ.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-C6K_wyko.css">
</head>
<body class="bg-gray-50">
+79 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
// UmindIndex renderiza el panel de administración de uMind.
@@ -50,6 +51,12 @@ func GetUmindTenants(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
items = tenantsVisibles(c, items)
if _, esStaff := middlewares.UmindScopeDe(c); !esStaff {
// Para el cliente la paginación global no tiene sentido (ve pocos
// tenants); el total pasa a ser el de lo que realmente puede ver.
total = int64(len(items))
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
@@ -109,6 +116,9 @@ func UpdateUmindTenantHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoTenant(c, uint(id)); err != nil {
return err
}
var req umindTenantReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
@@ -132,6 +142,9 @@ func DeleteUmindTenantHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoTenant(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindTenant(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -148,6 +161,12 @@ func GetUmindAgentesHandler(c *fiber.Ctx) error {
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetUmindAgentesByTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -176,9 +195,15 @@ func CreateUmindAgenteHandler(c *fiber.Ctx) error {
if strings.TrimSpace(req.Nombre) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
}
if err := accesoTenant(c, req.TenantID); err != nil {
return err
}
if _, err := models.GetUmindTenantByID(req.TenantID); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
}
if err := verificarCupoAgentes(c, req.TenantID); err != nil {
return err
}
agente := &models.UmindAgente{
TenantID: req.TenantID,
@@ -200,6 +225,9 @@ func UpdateUmindAgenteHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAgente(c, uint(id)); err != nil {
return err
}
var req umindAgenteReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
@@ -223,6 +251,9 @@ func DeleteUmindAgenteHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAgente(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindAgente(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -236,6 +267,9 @@ func GetUmindDocumentosHandler(c *fiber.Ctx) error {
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindDocumentosByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -258,6 +292,9 @@ func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
if strings.TrimSpace(req.URL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url requerida"})
}
@@ -285,6 +322,9 @@ func DeleteUmindDocumentoHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoDocumento(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindDocumento(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -298,6 +338,9 @@ func GetUmindSesionesHandler(c *fiber.Ctx) error {
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindSesiones(uint(agenteID), 50)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -310,6 +353,9 @@ func GetUmindHistorialHandler(c *fiber.Ctx) error {
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
sessionID := c.Query("session_id")
if sessionID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "session_id requerido"})
@@ -330,6 +376,9 @@ func GetUmindHerramientasHandler(c *fiber.Ctx) error {
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindHerramientasByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -365,6 +414,9 @@ func CreateUmindHerramientaHandler(c *fiber.Ctx) error {
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
if !umindNombreToolRegex.MatchString(req.Nombre) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
}
@@ -400,6 +452,9 @@ func UpdateUmindHerramientaHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoHerramienta(c, uint(id)); err != nil {
return err
}
var req umindHerramientaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
@@ -442,6 +497,9 @@ func DeleteUmindHerramientaHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoHerramienta(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindHerramienta(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -455,6 +513,9 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindCanalesByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -493,6 +554,9 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
switch req.Tipo {
case "telegram":
if strings.TrimSpace(req.Credenciales["bot_token"]) == "" {
@@ -534,6 +598,9 @@ func UpdateUmindCanalHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoCanal(c, uint(id)); err != nil {
return err
}
var req umindCanalReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
@@ -558,6 +625,9 @@ func DeleteUmindCanalHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoCanal(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindCanal(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -581,9 +651,9 @@ func UmindChatPruebaHandler(c *fiber.Ctx) error {
if strings.TrimSpace(req.Mensaje) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "mensaje requerido"})
}
agente, err := models.GetUmindAgenteByID(req.AgenteID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
agente, errAcceso := accesoAgente(c, req.AgenteID)
if errAcceso != nil {
return errAcceso
}
sessionID := strings.TrimSpace(req.SessionID)
if sessionID == "" {
@@ -606,6 +676,9 @@ func GetUmindEventosHandler(c *fiber.Ctx) error {
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindEventosByAgente(uint(agenteID), 100)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -623,6 +696,9 @@ func GetUmindUsoHandler(c *fiber.Ctx) error {
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
+8 -2
View File
@@ -17,6 +17,9 @@ func GetUmindConexionesHandler(c *fiber.Ctx) error {
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindConexionesByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -39,8 +42,8 @@ func UmindConectarHandler(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
proveedor := c.Query("proveedor")
if _, err := models.GetUmindAgenteByID(uint(agenteID)); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
url, err := services.IniciarConexionOAuth(proveedor, uint(agenteID))
@@ -81,6 +84,9 @@ func DeleteUmindConexionHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoConexion(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindConexion(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+140
View File
@@ -0,0 +1,140 @@
package controllers
import (
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
// errSinAcceso es la respuesta uniforme cuando el que llama no tiene alcance
// sobre el recurso. Se usa 404 y no 403 a propósito: un 403 confirmaría que
// el tenant/agente existe, y eso ya es información de otro cliente.
func errSinAcceso(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
}
// accesoTenant valida que el que llama pueda operar sobre ese tenant.
func accesoTenant(c *fiber.Ctx, tenantID uint) error {
if tenantID == 0 || !middlewares.UmindPuedeVerTenant(c, tenantID) {
return errSinAcceso(c)
}
return nil
}
// accesoAgente resuelve el agente a su tenant y valida el alcance. Devuelve el
// agente ya cargado para no volver a buscarlo en el handler.
func accesoAgente(c *fiber.Ctx, agenteID uint) (*models.UmindAgente, error) {
if agenteID == 0 {
return nil, errSinAcceso(c)
}
agente, err := models.GetUmindAgenteByID(agenteID)
if err != nil {
return nil, errSinAcceso(c)
}
if !middlewares.UmindPuedeVerTenant(c, agente.TenantID) {
return nil, errSinAcceso(c)
}
return agente, nil
}
// Los sub-recursos (documento, tool, canal, conexión) llegan por :id y no
// dicen a qué agente pertenecen: hay que cargarlos para resolver el dueño
// antes de dejar operar sobre ellos. Sin esto, un cliente podría borrar el
// canal de otro adivinando el id.
func accesoDocumento(c *fiber.Ctx, id uint) error {
doc, err := models.GetUmindDocumentoByID(id)
if err != nil {
return errSinAcceso(c)
}
_, e := accesoAgente(c, doc.AgenteID)
return e
}
func accesoHerramienta(c *fiber.Ctx, id uint) error {
h, err := models.GetUmindHerramientaByID(id)
if err != nil {
return errSinAcceso(c)
}
_, e := accesoAgente(c, h.AgenteID)
return e
}
func accesoCanal(c *fiber.Ctx, id uint) error {
canal, err := models.GetUmindCanalByID(id)
if err != nil {
return errSinAcceso(c)
}
_, e := accesoAgente(c, canal.AgenteID)
return e
}
// verificarCupoAgentes aplica el límite de agentes del plan. Un tenant sin
// plan asignado (los creados antes de que existieran los planes) no tiene
// límite: cortarles el paso de golpe sería peor que dejarlos como estaban.
func verificarCupoAgentes(c *fiber.Ctx, tenantID uint) error {
plan := models.GetPlanDeTenant(tenantID)
if plan == nil || plan.MaxAgentes <= 0 {
return nil
}
agentes, err := models.GetUmindAgentesByTenant(tenantID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(agentes) >= plan.MaxAgentes {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": fmt.Sprintf("Tu plan %s permite %d agente(s) y ya los estás usando. Escribinos para ampliarlo.",
plan.Nombre, plan.MaxAgentes),
})
}
return nil
}
// tenantsVisibles filtra una lista de tenants al alcance del que llama.
func tenantsVisibles(c *fiber.Ctx, items []models.UmindTenant) []models.UmindTenant {
if _, esStaff := middlewares.UmindScopeDe(c); esStaff {
return items
}
out := make([]models.UmindTenant, 0, len(items))
for _, t := range items {
if middlewares.UmindPuedeVerTenant(c, t.ID) {
out = append(out, t)
}
}
return out
}
func accesoConexion(c *fiber.Ctx, id uint) error {
cx, err := models.GetUmindConexionByID(id)
if err != nil {
return errSinAcceso(c)
}
_, e := accesoAgente(c, cx.AgenteID)
return e
}
// GetUmindAiConfigsHandler devuelve las configs de IA que el que llama puede
// usar: las globales del staff más las propias de sus tenants. Es el
// reemplazo con alcance de /app/api/ai-config/select, que devolvía TODAS las
// configs del sistema — servírselo a un cliente le mostraría las de los demás.
func GetUmindAiConfigsHandler(c *fiber.Ctx) error {
tenantIDs, esStaff := middlewares.UmindScopeDe(c)
var items []models.AiConfig
var err error
if esStaff {
items, err = models.GetAiConfigSelect()
} else {
items, err = models.GetAiConfigSelectPorTenants(tenantIDs)
}
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]fiber.Map, len(items))
for i, it := range items {
out[i] = fiber.Map{"ID": it.ID, "nombre": it.Nombre, "provider": it.Provider}
}
return c.JSON(fiber.Map{"items": out})
}
+80
View File
@@ -0,0 +1,80 @@
package middlewares
import (
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// umindScopeKey guarda en el contexto los tenants que el que llama puede ver.
//
// nil → staff, sin restricción
// []uint{} → no ve nada (fail-closed)
// []uint{1, 7} → solo esos tenants
//
// Es el ÚNICO lugar donde se decide el alcance: los handlers de uMind se
// montan bajo /app/umind (staff) y /portal/umind (cliente) con distinto
// middleware de scope, pero comparten implementación. Un bug acá es una fuga
// de datos entre clientes, así que no se replica esta lógica en ningún lado.
const umindScopeKey = "umind_tenant_ids"
// UmindScopeStaff deja el scope sin restricción. Existe como middleware
// explícito (en vez de "no poner nada") para que una ruta sin scope sea un
// error visible y no un permiso implícito.
func UmindScopeStaff(c *fiber.Ctx) error {
c.Locals(umindScopeKey, []uint(nil))
return c.Next()
}
// UmindScopePortal acota al cliente autenticado en el portal: sus tenants son
// los de los Cliente a los que tiene acceso (uno si es cliente, varios si es
// partner), resueltos por el mismo GetClienteIDsForPortalUser que ya autoriza
// el resto del portal.
func UmindScopePortal(c *fiber.Ctx) error {
u := PortalUserFromLocals(c)
if u == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autenticado"})
}
clienteIDs := models.GetClienteIDsForPortalUser(u)
tenants, err := models.GetUmindTenantsByClientes(clienteIDs)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo resolver el alcance"})
}
// Siempre un slice no-nil, incluso vacío: nil significa "staff, sin
// restricción", y confundir los dos le daría acceso total a un cliente.
ids := make([]uint, 0, len(tenants))
for _, t := range tenants {
ids = append(ids, t.ID)
}
c.Locals(umindScopeKey, ids)
return c.Next()
}
// UmindScopeDe devuelve (tenants permitidos, esStaff).
func UmindScopeDe(c *fiber.Ctx) ([]uint, bool) {
v := c.Locals(umindScopeKey)
if v == nil {
// Ruta sin middleware de scope: fail-closed. Mejor romper visiblemente
// que servir datos de todos los clientes por omisión.
return []uint{}, false
}
ids, ok := v.([]uint)
if !ok {
return []uint{}, false
}
return ids, ids == nil
}
// UmindPuedeVerTenant es el chequeo que hacen los handlers.
func UmindPuedeVerTenant(c *fiber.Ctx, tenantID uint) bool {
ids, esStaff := UmindScopeDe(c)
if esStaff {
return true
}
for _, id := range ids {
if id == tenantID {
return true
}
}
return false
}
+63
View File
@@ -0,0 +1,63 @@
package middlewares
import (
"testing"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
)
// El aislamiento entre clientes es la única lógica nueva donde un bug
// significa que un cliente vea los datos de otro. Estos casos cubren el
// contrato de UmindScopeDe/UmindPuedeVerTenant sin necesidad de base de datos.
func TestUmindPuedeVerTenant(t *testing.T) {
app := fiber.New()
casos := []struct {
nombre string
scope interface{} // lo que quedó en Locals
tenantID uint
esperado bool
}{
{"staff ve cualquier tenant", []uint(nil), 42, true},
{"cliente ve el suyo", []uint{7, 9}, 7, true},
{"cliente ve el otro suyo", []uint{7, 9}, 9, true},
{"cliente NO ve el de otro", []uint{7, 9}, 8, false},
{"cliente sin tenants no ve nada", []uint{}, 1, false},
{"ruta sin middleware de scope no ve nada", nil, 1, false},
{"scope de tipo inesperado no ve nada", "todos", 1, false},
}
for _, cas := range casos {
t.Run(cas.nombre, func(t *testing.T) {
c := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(c)
if cas.scope != nil {
c.Locals(umindScopeKey, cas.scope)
}
if got := UmindPuedeVerTenant(c, cas.tenantID); got != cas.esperado {
t.Errorf("UmindPuedeVerTenant(tenant %d) = %v, esperaba %v", cas.tenantID, got, cas.esperado)
}
})
}
}
// Un scope nil (staff) y uno vacío (cliente sin tenants) NO deben confundirse:
// tratar el vacío como staff le daría acceso total a un cliente sin tenants.
func TestScopeVacioNoEsStaff(t *testing.T) {
app := fiber.New()
c := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(c)
c.Locals(umindScopeKey, []uint{})
if _, esStaff := UmindScopeDe(c); esStaff {
t.Fatal("un scope vacío se está tratando como staff: un cliente sin tenants vería todo")
}
c2 := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(c2)
c2.Locals(umindScopeKey, []uint(nil))
if _, esStaff := UmindScopeDe(c2); !esStaff {
t.Fatal("un scope nil debería ser staff")
}
}
+13
View File
@@ -57,4 +57,17 @@ func PortalRoutes(app fiber.Router) {
portal.Post("/mi-perfil/telegram-init", controllers.PortalTelegramInit)
portal.Get("/mi-perfil/telegram-status", controllers.PortalTelegramStatus)
portal.Post("/mi-perfil/telegram-validar", controllers.PortalTelegramValidar)
// ─── uMind Studio para el cliente ──────────────────────────────────────────
// Los MISMOS handlers que usa el staff, pero con UmindScopePortal: el
// cliente solo alcanza los tenants de sus Cliente. Sin middleware de
// escritura extra — administrar lo suyo es justamente el punto.
RegistrarRutasUmind(portal, middlewares.UmindScopePortal, nil)
// El SPA (mismo build que /orchestrator) con fallback para vue-router.
studioFallback := func(c *fiber.Ctx) error {
return c.SendFile("./public/orchestrator/index.html")
}
portal.Get("/studio", studioFallback)
portal.Get("/studio/*", studioFallback)
}
+66
View File
@@ -0,0 +1,66 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/rest/controllers"
)
// RegistrarRutasUmind monta los mismos handlers de uMind bajo dos prefijos:
// /app/umind para el staff y /portal/umind para el cliente. Una sola
// implementación, dos historias de autenticación — duplicar los handlers sería
// duplicar también las chances de que uno se olvide un chequeo de alcance.
//
// scope decide QUÉ tenants ve el que llama (UmindScopeStaff/UmindScopePortal).
// escritura middleware extra para POST/PUT/DELETE (SoloAdmin del lado staff;
// nil del lado del cliente, que ya está acotado por su scope).
//
// El CRUD de tenants NO se registra acá a propósito: crear/borrar tenants y
// asignarles cliente y plan es del staff, el cliente solo administra lo que
// hay adentro.
func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Handler) {
// Concatena el scope con el middleware de escritura, si lo hay. El scope
// va SIEMPRE, también en las lecturas.
w := func(h ...fiber.Handler) []fiber.Handler {
out := []fiber.Handler{scope}
if escritura != nil {
out = append(out, escritura)
}
return append(out, h...)
}
r := func(h ...fiber.Handler) []fiber.Handler {
return append([]fiber.Handler{scope}, h...)
}
g.Get("/umind/tenants", r(controllers.GetUmindTenants)...)
g.Get("/umind/agentes", r(controllers.GetUmindAgentesHandler)...)
g.Post("/umind/agentes", w(controllers.CreateUmindAgenteHandler)...)
g.Put("/umind/agentes/:id", w(controllers.UpdateUmindAgenteHandler)...)
g.Delete("/umind/agentes/:id", w(controllers.DeleteUmindAgenteHandler)...)
g.Get("/umind/documentos", r(controllers.GetUmindDocumentosHandler)...)
g.Post("/umind/documentos", w(controllers.CreateUmindDocumentoHandler)...)
g.Delete("/umind/documentos/:id", w(controllers.DeleteUmindDocumentoHandler)...)
g.Get("/umind/sesiones", r(controllers.GetUmindSesionesHandler)...)
g.Get("/umind/historial", r(controllers.GetUmindHistorialHandler)...)
g.Get("/umind/tools", r(controllers.GetUmindHerramientasHandler)...)
g.Post("/umind/tools", w(controllers.CreateUmindHerramientaHandler)...)
g.Put("/umind/tools/:id", w(controllers.UpdateUmindHerramientaHandler)...)
g.Delete("/umind/tools/:id", w(controllers.DeleteUmindHerramientaHandler)...)
g.Get("/umind/canales", r(controllers.GetUmindCanalesHandler)...)
g.Post("/umind/canales", w(controllers.CreateUmindCanalHandler)...)
g.Put("/umind/canales/:id", w(controllers.UpdateUmindCanalHandler)...)
g.Delete("/umind/canales/:id", w(controllers.DeleteUmindCanalHandler)...)
g.Post("/umind/chat", r(controllers.UmindChatPruebaHandler)...)
g.Get("/umind/conexiones", r(controllers.GetUmindConexionesHandler)...)
g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...)
g.Delete("/umind/conexiones/:id", w(controllers.DeleteUmindConexionHandler)...)
g.Get("/umind/eventos", r(controllers.GetUmindEventosHandler)...)
g.Get("/umind/uso", r(controllers.GetUmindUsoHandler)...)
g.Get("/umind/ai-configs", r(controllers.GetUmindAiConfigsHandler)...)
}
+8 -28
View File
@@ -356,34 +356,14 @@ func UserRoutes(app fiber.Router) {
// desde qué dominios se puede llamar al widget, así que crear/editar es
// solo para administradores.
protected.Get("/umind", middlewares.MenuMiddleware, controllers.UmindIndex)
protected.Get("/umind/tenants", controllers.GetUmindTenants)
protected.Post("/umind/tenants", middlewares.SoloAdmin, controllers.CreateUmindTenantHandler)
protected.Put("/umind/tenants/:id", middlewares.SoloAdmin, controllers.UpdateUmindTenantHandler)
protected.Delete("/umind/tenants/:id", middlewares.SoloAdmin, controllers.DeleteUmindTenantHandler)
protected.Get("/umind/agentes", controllers.GetUmindAgentesHandler)
protected.Post("/umind/agentes", middlewares.SoloAdmin, controllers.CreateUmindAgenteHandler)
protected.Put("/umind/agentes/:id", middlewares.SoloAdmin, controllers.UpdateUmindAgenteHandler)
protected.Delete("/umind/agentes/:id", middlewares.SoloAdmin, controllers.DeleteUmindAgenteHandler)
protected.Get("/umind/documentos", controllers.GetUmindDocumentosHandler)
protected.Post("/umind/documentos", middlewares.SoloAdmin, controllers.CreateUmindDocumentoHandler)
protected.Delete("/umind/documentos/:id", middlewares.SoloAdmin, controllers.DeleteUmindDocumentoHandler)
protected.Get("/umind/sesiones", controllers.GetUmindSesionesHandler)
protected.Get("/umind/historial", controllers.GetUmindHistorialHandler)
protected.Get("/umind/tools", controllers.GetUmindHerramientasHandler)
protected.Post("/umind/tools", middlewares.SoloAdmin, controllers.CreateUmindHerramientaHandler)
protected.Put("/umind/tools/:id", middlewares.SoloAdmin, controllers.UpdateUmindHerramientaHandler)
protected.Delete("/umind/tools/:id", middlewares.SoloAdmin, controllers.DeleteUmindHerramientaHandler)
protected.Get("/umind/canales", controllers.GetUmindCanalesHandler)
protected.Post("/umind/canales", middlewares.SoloAdmin, controllers.CreateUmindCanalHandler)
protected.Put("/umind/canales/:id", middlewares.SoloAdmin, controllers.UpdateUmindCanalHandler)
protected.Delete("/umind/canales/:id", middlewares.SoloAdmin, controllers.DeleteUmindCanalHandler)
protected.Post("/umind/chat", controllers.UmindChatPruebaHandler)
protected.Get("/umind/conexiones", controllers.GetUmindConexionesHandler)
protected.Get("/umind/conexiones/conectar", middlewares.SoloAdmin, controllers.UmindConectarHandler)
protected.Get("/umind/conexiones/callback/:proveedor", controllers.UmindOAuthCallbackHandler)
protected.Delete("/umind/conexiones/:id", middlewares.SoloAdmin, controllers.DeleteUmindConexionHandler)
protected.Get("/umind/eventos", controllers.GetUmindEventosHandler)
protected.Get("/umind/uso", controllers.GetUmindUsoHandler)
// Las escrituras siguen siendo SoloAdmin del lado del staff; el alcance
// (qué tenants ve cada quien) lo decide el middleware de scope, que es el
// mismo contrato para /app/umind y /portal/umind.
RegistrarRutasUmind(protected, middlewares.UmindScopeStaff, middlewares.SoloAdmin)
protected.Post("/umind/tenants", middlewares.SoloAdmin, middlewares.UmindScopeStaff, controllers.CreateUmindTenantHandler)
protected.Put("/umind/tenants/:id", middlewares.SoloAdmin, middlewares.UmindScopeStaff, controllers.UpdateUmindTenantHandler)
protected.Delete("/umind/tenants/:id", middlewares.SoloAdmin, middlewares.UmindScopeStaff, controllers.DeleteUmindTenantHandler)
protected.Get("/umind/conexiones/callback/:proveedor", middlewares.UmindScopeStaff, controllers.UmindOAuthCallbackHandler)
// Planes: definen el límite de agentes y los precios por consumo. Solo staff.
protected.Get("/umind-planes", middlewares.MenuMiddleware, controllers.UmindPlanesPage)
protected.Get("/umind-planes/list", controllers.GetUmindPlanesHandler)