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:
co-authored by
Claude Sonnet 5
parent
08265510ea
commit
5b78f6677c
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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 },
|
||||
|
||||
@@ -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 || []
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user