feat: conexiones OAuth (Gmail/Outlook) para el agente + rediseño del orquestador

Backend:
- UmindConexion: cuenta de correo conectada por tenant vía OAuth2
  (golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados
  en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales.
- Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft,
  /callback/:proveedor intercambia el code (state autoverificable por HMAC,
  sin tabla de estados pendientes), refresh on-demand antes de cada uso.
- Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen
  solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft
  Graph directo (sin el SDK pesado de Google).
- Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud
  Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y
  MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un
  mensaje claro, no en silencio.

Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el
navbar + lista de página completa), modo oscuro vía prefers-color-scheme,
tabs en pill, y la nueva tab "Conexiones".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-12 10:25:13 -05:00
co-authored by Claude Sonnet 5
parent b2f6b518b6
commit 5ba41786d6
27 changed files with 1254 additions and 396 deletions
+228
View File
@@ -0,0 +1,228 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api.js'
const route = useRoute()
const router = useRouter()
const tenants = ref([])
const aiConfigs = ref([])
const loading = ref(true)
const error = ref('')
const showForm = ref(false)
const editing = ref(null)
const form = ref(vacio())
function vacio() {
return {
nombre: '',
dominios_permitidos: '',
ai_config_id: null,
tono: '',
mensaje_bienvenida: '',
activo: true,
}
}
async function cargar() {
loading.value = true
error.value = ''
try {
const [t, ai] = await Promise.all([
api.get('/app/umind/tenants'),
api.get('/app/api/ai-config/select'),
])
tenants.value = t.items || []
aiConfigs.value = ai.registros || []
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
function nuevoTenant() {
editing.value = null
form.value = vacio()
showForm.value = true
}
function editarTenant(t) {
editing.value = t
form.value = {
nombre: t.nombre,
dominios_permitidos: t.dominios_permitidos,
ai_config_id: t.ai_config_id,
tono: t.tono,
mensaje_bienvenida: t.mensaje_bienvenida,
activo: t.activo,
}
showForm.value = true
}
async function guardar() {
const payload = {
...form.value,
dominios_permitidos: form.value.dominios_permitidos
.split(',')
.map((d) => d.trim())
.filter(Boolean),
}
try {
if (editing.value) {
await api.put(`/app/umind/tenants/${editing.value.ID}`, payload)
showForm.value = false
await cargar()
} else {
const r = await api.post('/app/umind/tenants', payload)
showForm.value = false
await cargar()
router.push(`/tenants/${r.id}`)
}
} catch (e) {
error.value = e.message
}
}
async function eliminar(t) {
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto no se puede deshacer.`)) return
await api.del(`/app/umind/tenants/${t.ID}`)
if (route.params.id === String(t.ID)) router.push('/')
await cargar()
}
defineExpose({ recargar: cargar })
onMounted(cargar)
</script>
<template>
<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>
</router-link>
</div>
<div 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"
>
+ Nuevo tenant
</button>
</div>
<p v-if="error" class="px-3 pt-2 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
<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>
<div
v-for="t in tenants"
:key="t.ID"
class="group flex items-center rounded-lg transition-colors"
:class="route.params.id === String(t.ID) ? 'bg-brand/10 dark:bg-brand/20' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
>
<router-link
:to="`/tenants/${t.ID}`"
class="flex-1 min-w-0 px-2.5 py-2 text-sm"
:class="route.params.id === String(t.ID) ? 'text-brand-dark dark:text-brand font-medium' : 'text-gray-700 dark:text-gray-300'"
>
<div class="truncate">{{ t.nombre }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span class="w-1.5 h-1.5 rounded-full" :class="t.activo ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"></span>
<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">
<button
class="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
title="Editar"
@click="editarTenant(t)"
>
</button>
<button
class="p-1 text-gray-400 hover:text-red-600"
title="Eliminar"
@click="eliminar(t)"
>
</button>
</div>
</div>
</nav>
</aside>
<!-- Modal de alta/edición -->
<div
v-if="showForm"
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="showForm = false"
>
<div class="bg-white dark:bg-gray-900 rounded-xl p-6 w-full max-w-lg border border-gray-200 dark:border-gray-800">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
</h2>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Nombre</label>
<input
v-model="form.nombre"
required
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Dominios permitidos (separados por coma)</label>
<input
v-model="form.dominios_permitidos"
placeholder="ejemplo.com, www.ejemplo.com"
required
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Config de IA</label>
<select
v-model="form.ai_config_id"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
>
<option :value="null"> sin asignar </option>
<option v-for="c in aiConfigs" :key="c.ID" :value="c.ID">
{{ c.nombre }} ({{ c.provider }})
</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Tono / personalidad</label>
<textarea
v-model="form.tono"
rows="2"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
></textarea>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Mensaje de bienvenida</label>
<input
v-model="form.mensaje_bienvenida"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
/>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input v-model="form.activo" type="checkbox" />
Activo
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showForm = false">
Cancelar
</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">
Guardar
</button>
</div>
</form>
</div>
</div>
</template>