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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user