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
+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"