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:
co-authored by
Claude Sonnet 5
parent
b2f6b518b6
commit
5ba41786d6
@@ -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>
|
||||
Reference in New Issue
Block a user