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
@@ -51,6 +51,14 @@ token:
|
||||
app_jwt_secret: SECRET_APP
|
||||
api_jwt_secret: SECRET_API
|
||||
expires_in: 31536000
|
||||
# Conexiones OAuth de uMind (Gmail/Outlook) — vacío hasta crear las apps en
|
||||
# Google Cloud Console / Azure. Se completan por variable de entorno
|
||||
# (GOOGLE_OAUTH_CLIENT_ID, etc.), no acá.
|
||||
oauth:
|
||||
google_client_id: ""
|
||||
google_client_secret: ""
|
||||
ms_client_id: ""
|
||||
ms_client_secret: ""
|
||||
jwt:
|
||||
app:
|
||||
secret: SECRET_APP
|
||||
|
||||
@@ -55,6 +55,14 @@ token:
|
||||
app_jwt_secret: SECRET_APP
|
||||
api_jwt_secret: SECRET_API
|
||||
expires_in: 31536000
|
||||
# Conexiones OAuth de uMind (Gmail/Outlook) — vacío hasta crear las apps en
|
||||
# Google Cloud Console / Azure. Se completan por variable de entorno
|
||||
# (GOOGLE_OAUTH_CLIENT_ID, etc.), no acá.
|
||||
oauth:
|
||||
google_client_id: ""
|
||||
google_client_secret: ""
|
||||
ms_client_id: ""
|
||||
ms_client_secret: ""
|
||||
jwt:
|
||||
app:
|
||||
secret: SECRET_APP
|
||||
|
||||
@@ -28,6 +28,7 @@ type AppConfig struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
Token Token `yaml:"token"`
|
||||
OAuth OAuthConfig `yaml:"oauth"`
|
||||
Profiler ProfilerConfig `yaml:"profiler"`
|
||||
Flash *flash.Flash
|
||||
ConfigFile string
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package config
|
||||
|
||||
// OAuthConfig trae las credenciales de las apps OAuth para conectar cuentas
|
||||
// de correo (uMind: enviar/leer correo en nombre de un tenant). Sin
|
||||
// env-default, igual que los secretos JWT — se cargan por variable de
|
||||
// entorno, no quedan escritos en config.yml.
|
||||
type OAuthConfig struct {
|
||||
GoogleClientID string `mapstructure:"GOOGLE_OAUTH_CLIENT_ID" yaml:"google_client_id" env:"GOOGLE_OAUTH_CLIENT_ID"`
|
||||
GoogleClientSecret string `mapstructure:"GOOGLE_OAUTH_CLIENT_SECRET" yaml:"google_client_secret" env:"GOOGLE_OAUTH_CLIENT_SECRET"`
|
||||
MSClientID string `mapstructure:"MS_OAUTH_CLIENT_ID" yaml:"ms_client_id" env:"MS_OAUTH_CLIENT_ID"`
|
||||
MSClientSecret string `mapstructure:"MS_OAUTH_CLIENT_SECRET" yaml:"ms_client_secret" env:"MS_OAUTH_CLIENT_SECRET"`
|
||||
}
|
||||
@@ -57,11 +57,13 @@ require (
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
go.mongodb.org/mongo-driver v1.17.9
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
gorm.io/driver/sqlserver v1.5.3
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
@@ -144,7 +146,6 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/oauth2 v0.23.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
|
||||
@@ -33,6 +33,8 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||
|
||||
@@ -153,6 +153,7 @@ func main() {
|
||||
&models.UmindMensaje{},
|
||||
&models.UmindHerramienta{},
|
||||
&models.UmindCanal{},
|
||||
&models.UmindConexion{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script setup>
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<header class="bg-white border-b border-gray-200">
|
||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<router-link to="/" class="text-lg font-semibold text-gray-800">
|
||||
uMind <span class="text-brand">Orquestador</span>
|
||||
</router-link>
|
||||
<a href="/app/dashboard" class="text-sm text-gray-500 hover:text-gray-700">
|
||||
← Volver al panel
|
||||
</a>
|
||||
<div class="min-h-screen flex bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar />
|
||||
<main class="flex-1 min-w-0">
|
||||
<div class="max-w-4xl mx-auto px-8 py-10">
|
||||
<router-view />
|
||||
</div>
|
||||
</header>
|
||||
<main class="max-w-6xl mx-auto px-6 py-8">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import TenantsList from './views/TenantsList.vue'
|
||||
import Home from './views/Home.vue'
|
||||
import TenantDetail from './views/TenantDetail.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/orchestrator/'),
|
||||
routes: [
|
||||
{ path: '/', name: 'tenants', component: TenantsList },
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/tenants/:id', name: 'tenant-detail', component: TenantDetail, props: true },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
const tenantId = computed(() => Number(props.id))
|
||||
const route = useRoute()
|
||||
|
||||
const tenant = ref(null)
|
||||
const error = ref('')
|
||||
const tab = ref('conocimiento')
|
||||
const tab = ref(typeof route.query.tab === 'string' ? route.query.tab : 'conocimiento')
|
||||
|
||||
// ─── Base de conocimiento ───────────────────────────────────────────────────
|
||||
const documentos = ref([])
|
||||
@@ -51,11 +53,11 @@ async function eliminarDocumento(id) {
|
||||
}
|
||||
|
||||
const estadoColor = computed(() => (estado) => ({
|
||||
listo: 'bg-green-100 text-green-700',
|
||||
procesando: 'bg-amber-100 text-amber-700',
|
||||
pendiente: 'bg-gray-100 text-gray-500',
|
||||
error: 'bg-red-100 text-red-700',
|
||||
}[estado] || 'bg-gray-100 text-gray-500'))
|
||||
listo: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400',
|
||||
procesando: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
|
||||
pendiente: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
|
||||
}[estado] || 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'))
|
||||
|
||||
// ─── Conversaciones ──────────────────────────────────────────────────────────
|
||||
const sesiones = ref([])
|
||||
@@ -81,14 +83,8 @@ const toolForm = ref(toolVacio())
|
||||
|
||||
function toolVacio() {
|
||||
return {
|
||||
nombre: '',
|
||||
descripcion: '',
|
||||
url: '',
|
||||
auth_header_nombre: '',
|
||||
auth_header_valor: '',
|
||||
tocarAuth: false,
|
||||
parametros: [],
|
||||
activa: true,
|
||||
nombre: '', descripcion: '', url: '', auth_header_nombre: '', auth_header_valor: '',
|
||||
tocarAuth: false, parametros: [], activa: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +108,9 @@ function editarTool(t) {
|
||||
parametros = []
|
||||
}
|
||||
toolForm.value = {
|
||||
nombre: t.nombre,
|
||||
descripcion: t.descripcion,
|
||||
url: t.url,
|
||||
auth_header_nombre: t.auth_header_nombre,
|
||||
auth_header_valor: '',
|
||||
tocarAuth: false,
|
||||
parametros,
|
||||
activa: t.activa,
|
||||
nombre: t.nombre, descripcion: t.descripcion, url: t.url,
|
||||
auth_header_nombre: t.auth_header_nombre, auth_header_valor: '', tocarAuth: false,
|
||||
parametros, activa: t.activa,
|
||||
}
|
||||
showToolForm.value = true
|
||||
}
|
||||
@@ -142,8 +133,6 @@ async function guardarTool() {
|
||||
parametros: toolForm.value.parametros,
|
||||
activa: toolForm.value.activa,
|
||||
}
|
||||
// auth_header_valor solo va si el usuario efectivamente lo tocó — así una
|
||||
// edición sin cambiar el secreto no lo borra ni lo re-envía en claro.
|
||||
if (toolForm.value.tocarAuth) {
|
||||
payload.auth_header_valor = toolForm.value.auth_header_valor
|
||||
}
|
||||
@@ -173,12 +162,7 @@ const canalForm = ref(canalVacio())
|
||||
|
||||
function canalVacio() {
|
||||
return {
|
||||
tipo: 'telegram',
|
||||
bot_token: '',
|
||||
phone_number_id: '',
|
||||
access_token: '',
|
||||
app_secret: '',
|
||||
verify_token: '',
|
||||
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,12 +187,7 @@ async function guardarCanal() {
|
||||
verify_token: canalForm.value.verify_token,
|
||||
}
|
||||
try {
|
||||
await api.post('/app/umind/canales', {
|
||||
tenant_id: tenantId.value,
|
||||
tipo: canalForm.value.tipo,
|
||||
credenciales,
|
||||
activo: true,
|
||||
})
|
||||
await api.post('/app/umind/canales', { tenant_id: tenantId.value, tipo: canalForm.value.tipo, credenciales, activo: true })
|
||||
showCanalForm.value = false
|
||||
await cargarCanales()
|
||||
} catch (e) {
|
||||
@@ -227,6 +206,25 @@ async function eliminarCanal(c) {
|
||||
await cargarCanales()
|
||||
}
|
||||
|
||||
// ─── Conexiones (correo, OAuth) ────────────────────────────────────────────────
|
||||
const conexiones = ref([])
|
||||
|
||||
async function cargarConexiones() {
|
||||
const r = await api.get(`/app/umind/conexiones?tenant_id=${props.id}`)
|
||||
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?tenant_id=${tenantId.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 cargarConexiones()
|
||||
}
|
||||
|
||||
// ─── Chat de prueba ───────────────────────────────────────────────────────────
|
||||
const chatSessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
|
||||
const chatMensajes = ref([])
|
||||
@@ -240,11 +238,7 @@ async function enviarChatPrueba() {
|
||||
chatMensajes.value.push({ role: 'user', content: texto })
|
||||
chatEnviando.value = true
|
||||
try {
|
||||
const r = await api.post('/app/umind/chat', {
|
||||
tenant_id: tenantId.value,
|
||||
session_id: chatSessionId,
|
||||
mensaje: texto,
|
||||
})
|
||||
const r = await api.post('/app/umind/chat', { tenant_id: tenantId.value, session_id: chatSessionId, mensaje: texto })
|
||||
chatMensajes.value.push({ role: 'assistant', content: r.respuesta })
|
||||
} catch (e) {
|
||||
chatMensajes.value.push({ role: 'assistant', content: `⚠️ ${e.message}` })
|
||||
@@ -257,13 +251,14 @@ const tabs = [
|
||||
['conocimiento', 'Base de conocimiento'],
|
||||
['herramientas', 'Herramientas'],
|
||||
['canales', 'Canales'],
|
||||
['conexiones', 'Conexiones'],
|
||||
['chat', 'Chat de prueba'],
|
||||
['conversaciones', 'Conversaciones'],
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([cargarTenant(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales()])
|
||||
await Promise.all([cargarTenant(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones()])
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
@@ -272,23 +267,23 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<router-link to="/" class="text-sm text-gray-500 hover:text-gray-700">← Tenants</router-link>
|
||||
|
||||
<div v-if="tenant" class="mt-2 mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800">{{ tenant.nombre }}</h1>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
site_key: <code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ tenant.site_key }}</code>
|
||||
<div v-if="tenant" class="mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">{{ tenant.nombre }}</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
site_key: <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">{{ tenant.site_key }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 mb-4">{{ error }}</p>
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
|
||||
<div class="border-b border-gray-200 mb-6 flex gap-6 text-sm overflow-x-auto">
|
||||
<div class="flex gap-1.5 mb-6 overflow-x-auto pb-1">
|
||||
<button
|
||||
v-for="[key, label] in tabs"
|
||||
:key="key"
|
||||
class="pb-2 border-b-2 whitespace-nowrap"
|
||||
:class="tab === key ? 'border-brand text-brand font-medium' : 'border-transparent text-gray-500'"
|
||||
class="px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
|
||||
:class="tab === key
|
||||
? 'bg-brand text-white font-medium'
|
||||
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-800 hover:border-brand/50'"
|
||||
@click="tab = key"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -298,21 +293,21 @@ onMounted(async () => {
|
||||
<!-- Base de conocimiento -->
|
||||
<div v-if="tab === 'conocimiento'">
|
||||
<form class="flex gap-2 mb-4" @submit.prevent="agregarFuente">
|
||||
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<input v-model="maxPaginas" type="number" min="1" max="200" class="w-24 border border-gray-300 rounded-lg px-3 py-2 text-sm" title="Máximo de páginas a crawlear" />
|
||||
<button type="submit" :disabled="ingestando" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50">
|
||||
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
|
||||
<input v-model="maxPaginas" type="number" min="1" max="200" class="w-24 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" title="Máximo de páginas a crawlear" />
|
||||
<button type="submit" :disabled="ingestando" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50 transition-colors">
|
||||
{{ ingestando ? 'Agregando...' : 'Crawlear sitio' }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
<div v-if="documentos.length === 0" class="p-6 text-sm text-gray-500">Sin fuentes todavía.</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div v-if="documentos.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin fuentes todavía.</div>
|
||||
<div v-for="d in documentos" :key="d.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-gray-800">{{ d.origen }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200">{{ d.origen }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-500 mt-0.5">
|
||||
<span class="px-1.5 py-0.5 rounded" :class="estadoColor(d.estado)">{{ d.estado }}</span>
|
||||
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
|
||||
<span v-if="d.error" class="text-red-600"> · {{ d.error }}</span>
|
||||
<span v-if="d.error" class="text-red-600 dark:text-red-400"> · {{ d.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
|
||||
@@ -323,61 +318,61 @@ onMounted(async () => {
|
||||
<!-- Herramientas -->
|
||||
<div v-else-if="tab === 'herramientas'">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<p class="text-xs text-gray-500">Máximo 10 tools activas por tenant.</p>
|
||||
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg" @click="nuevaTool">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Máximo 10 tools activas por tenant.</p>
|
||||
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" @click="nuevaTool">
|
||||
+ Nueva tool
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
<div v-if="tools.length === 0" class="p-6 text-sm text-gray-500">Sin tools custom todavía.</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div v-if="tools.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin tools custom todavía.</div>
|
||||
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-gray-800 font-mono">{{ t.nombre }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">{{ t.descripcion }}</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200 font-mono">{{ t.nombre }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t.descripcion }}</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{{ t.url }}
|
||||
<span v-if="t.auth_configurado" class="ml-1 text-green-600">· auth configurada</span>
|
||||
<span v-if="t.auth_configurado" class="ml-1 text-green-600 dark:text-green-400">· auth configurada</span>
|
||||
<span v-if="!t.activa" class="ml-1 text-gray-400">· inactiva</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 text-sm shrink-0">
|
||||
<button class="text-gray-500 hover:text-gray-800" @click="editarTool(t)">Editar</button>
|
||||
<button class="text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" @click="editarTool(t)">Editar</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click="eliminarTool(t)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showToolForm" class="fixed inset-0 bg-black/30 flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
|
||||
<div class="bg-white rounded-xl p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<h2 class="font-semibold text-gray-800 mb-4">{{ editingTool ? 'Editar tool' : 'Nueva tool' }}</h2>
|
||||
<div v-if="showToolForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editingTool ? 'Editar tool' : 'Nueva tool' }}</h2>
|
||||
<form class="space-y-3" @submit.prevent="guardarTool">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Nombre (identificador, ej: consultar_stock)</label>
|
||||
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Nombre (identificador, ej: consultar_stock)</label>
|
||||
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" 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 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
|
||||
<textarea v-model="toolForm.descripcion" rows="2" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"></textarea>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
|
||||
<textarea v-model="toolForm.descripcion" rows="2" 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"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">URL del webhook (https)</label>
|
||||
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">URL del webhook (https)</label>
|
||||
<input v-model="toolForm.url" type="url" required placeholder="https://..." 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 class="border border-gray-200 rounded-lg p-3 space-y-2">
|
||||
<div class="border border-gray-200 dark:border-gray-800 rounded-lg p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-gray-500">Parámetros que completa el modelo</label>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Parámetros que completa el modelo</label>
|
||||
<button type="button" class="text-xs text-brand" @click="agregarParametro">+ agregar</button>
|
||||
</div>
|
||||
<div v-for="(p, i) in toolForm.parametros" :key="i" class="flex gap-2 items-center">
|
||||
<input v-model="p.nombre" placeholder="nombre" class="flex-1 border border-gray-300 rounded px-2 py-1 text-xs font-mono" />
|
||||
<select v-model="p.tipo" class="border border-gray-300 rounded px-2 py-1 text-xs">
|
||||
<input v-model="p.nombre" placeholder="nombre" class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded px-2 py-1 text-xs font-mono" />
|
||||
<select v-model="p.tipo" class="border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded px-2 py-1 text-xs">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-gray-300 rounded px-2 py-1 text-xs" />
|
||||
<label class="text-xs text-gray-500 flex items-center gap-1">
|
||||
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded px-2 py-1 text-xs" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<input v-model="p.requerido" type="checkbox" /> req.
|
||||
</label>
|
||||
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)">✕</button>
|
||||
@@ -385,10 +380,10 @@ onMounted(async () => {
|
||||
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-3 space-y-2">
|
||||
<label class="text-xs text-gray-500">Autenticación saliente (opcional)</label>
|
||||
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<div class="border border-gray-200 dark:border-gray-800 rounded-lg p-3 space-y-2">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Autenticación saliente (opcional)</label>
|
||||
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" 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" />
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<input v-model="toolForm.tocarAuth" type="checkbox" />
|
||||
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
|
||||
</label>
|
||||
@@ -397,15 +392,15 @@ onMounted(async () => {
|
||||
v-model="toolForm.auth_header_valor"
|
||||
type="password"
|
||||
placeholder="Valor del header (ej: Bearer xxxx)"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
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">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<input v-model="toolForm.activa" type="checkbox" /> Activa
|
||||
</label>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" class="px-4 py-2 text-sm text-gray-500" @click="showToolForm = false">Cancelar</button>
|
||||
<button type="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showToolForm = 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>
|
||||
@@ -416,76 +411,74 @@ onMounted(async () => {
|
||||
<!-- Canales -->
|
||||
<div v-else-if="tab === 'canales'">
|
||||
<div class="flex justify-end mb-4">
|
||||
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg" @click="nuevoCanal">
|
||||
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" @click="nuevoCanal">
|
||||
+ Nuevo canal
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
<div v-if="canales.length === 0" class="p-6 text-sm text-gray-500">Sin canales configurados.</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div v-if="canales.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin canales configurados.</div>
|
||||
<div v-for="c in canales" :key="c.ID" class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium text-gray-800 capitalize">{{ c.tipo }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200 capitalize">{{ c.tipo }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'">
|
||||
{{ c.activo ? 'activo' : 'inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-3 text-sm">
|
||||
<button class="text-gray-500 hover:text-gray-800" @click="toggleCanal(c)">
|
||||
<button class="text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" @click="toggleCanal(c)">
|
||||
{{ c.activo ? 'Desactivar' : 'Activar' }}
|
||||
</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click="eliminarCanal(c)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1 break-all">
|
||||
Webhook: <code class="bg-gray-100 px-1 rounded">{{ c.webhook_url }}</code>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all">
|
||||
Webhook: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ c.webhook_url }}</code>
|
||||
</p>
|
||||
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-gray-400 mt-1">
|
||||
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Registrá esta URL como "Callback URL" en Meta for Developers → WhatsApp → Configuration, con el mismo verify_token que pusiste acá.
|
||||
</p>
|
||||
<p v-if="c.ultimo_error" class="text-xs text-red-600 mt-1">{{ c.ultimo_error }}</p>
|
||||
<p v-if="c.ultimo_error" class="text-xs text-red-600 dark:text-red-400 mt-1">{{ c.ultimo_error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showCanalForm" class="fixed inset-0 bg-black/30 flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
|
||||
<div class="bg-white rounded-xl p-6 w-full max-w-md">
|
||||
<h2 class="font-semibold text-gray-800 mb-4">Nuevo canal</h2>
|
||||
<div v-if="showCanalForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-md">
|
||||
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">Nuevo canal</h2>
|
||||
<form class="space-y-3" @submit.prevent="guardarCanal">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Tipo</label>
|
||||
<select v-model="canalForm.tipo" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Tipo</label>
|
||||
<select v-model="canalForm.tipo" 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="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp Business</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template v-if="canalForm.tipo === 'telegram'">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Bot token (de @BotFather)</label>
|
||||
<input v-model="canalForm.bot_token" type="password" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Bot token (de @BotFather)</label>
|
||||
<input v-model="canalForm.bot_token" type="password" 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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Phone Number ID</label>
|
||||
<input v-model="canalForm.phone_number_id" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Phone Number ID</label>
|
||||
<input v-model="canalForm.phone_number_id" 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">Access Token</label>
|
||||
<input v-model="canalForm.access_token" type="password" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Access Token</label>
|
||||
<input v-model="canalForm.access_token" type="password" 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">App Secret</label>
|
||||
<input v-model="canalForm.app_secret" type="password" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">App Secret</label>
|
||||
<input v-model="canalForm.app_secret" type="password" 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">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
|
||||
<input v-model="canalForm.verify_token" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
|
||||
<input v-model="canalForm.verify_token" 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>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" class="px-4 py-2 text-sm text-gray-500" @click="showCanalForm = false">Cancelar</button>
|
||||
<button type="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showCanalForm = 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>
|
||||
@@ -493,25 +486,54 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conexiones (correo, OAuth) -->
|
||||
<div v-else-if="tab === 'conexiones'">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Conectá una cuenta de correo para que el agente pueda enviar y leer correo en nombre del negocio.
|
||||
Se soporta una cuenta activa a la vez.
|
||||
</p>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button class="border border-gray-300 dark:border-gray-700 hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('google')">
|
||||
Conectar Google
|
||||
</button>
|
||||
<button class="border border-gray-300 dark:border-gray-700 hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('microsoft')">
|
||||
Conectar Outlook
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div v-if="conexiones.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin cuentas conectadas.</div>
|
||||
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200 capitalize">{{ c.proveedor }}</span>
|
||||
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{{ c.email }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'">
|
||||
{{ c.activo ? 'activa' : 'inactiva' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="desconectar(c)">Desconectar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat de prueba -->
|
||||
<div v-else-if="tab === 'chat'" class="bg-white rounded-xl border border-gray-200 p-4 flex flex-col h-[28rem]">
|
||||
<div v-else-if="tab === 'chat'" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 flex flex-col h-[28rem]">
|
||||
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
|
||||
<p v-if="chatMensajes.length === 0" class="text-sm text-gray-500">
|
||||
<p v-if="chatMensajes.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Probá el agente tal cual lo va a ver un visitante — usa la misma config de IA y las mismas tools/base de conocimiento del tenant.
|
||||
</p>
|
||||
<div
|
||||
v-for="(m, i) in chatMensajes"
|
||||
:key="i"
|
||||
class="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
|
||||
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 text-gray-800'"
|
||||
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100'"
|
||||
>
|
||||
{{ m.content }}
|
||||
</div>
|
||||
<p v-if="chatEnviando" class="text-xs text-gray-400">Pensando...</p>
|
||||
<p v-if="chatEnviando" class="text-xs text-gray-400 dark:text-gray-500">Pensando...</p>
|
||||
</div>
|
||||
<form class="flex gap-2" @submit.prevent="enviarChatPrueba">
|
||||
<input v-model="chatInput" placeholder="Escribí un mensaje de prueba..." class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<button type="submit" :disabled="chatEnviando" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50">
|
||||
<input v-model="chatInput" placeholder="Escribí un mensaje de prueba..." class="flex-1 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" />
|
||||
<button type="submit" :disabled="chatEnviando" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50 transition-colors">
|
||||
Enviar
|
||||
</button>
|
||||
</form>
|
||||
@@ -519,26 +541,26 @@ onMounted(async () => {
|
||||
|
||||
<!-- Conversaciones -->
|
||||
<div v-else class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-1 bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 max-h-[28rem] overflow-y-auto">
|
||||
<div v-if="sesiones.length === 0" class="p-4 text-sm text-gray-500">Sin conversaciones.</div>
|
||||
<div class="col-span-1 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800 max-h-[28rem] overflow-y-auto">
|
||||
<div v-if="sesiones.length === 0" class="p-4 text-sm text-gray-500 dark:text-gray-400">Sin conversaciones.</div>
|
||||
<button
|
||||
v-for="s in sesiones"
|
||||
:key="s.session_id"
|
||||
class="w-full text-left p-3 hover:bg-gray-50 text-sm"
|
||||
:class="sesionActiva === s.session_id ? 'bg-gray-50' : ''"
|
||||
class="w-full text-left p-3 hover:bg-gray-50 dark:hover:bg-gray-800 text-sm"
|
||||
:class="sesionActiva === s.session_id ? 'bg-gray-50 dark:bg-gray-800' : ''"
|
||||
@click="verHistorial(s.session_id)"
|
||||
>
|
||||
<div class="text-gray-800 truncate">{{ s.content }}</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">{{ s.session_id }}</div>
|
||||
<div class="text-gray-800 dark:text-gray-200 truncate">{{ s.content }}</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{{ s.session_id }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-span-2 bg-white rounded-xl border border-gray-200 p-4 max-h-[28rem] overflow-y-auto space-y-2">
|
||||
<p v-if="!sesionActiva" class="text-sm text-gray-500">Elegí una conversación de la izquierda.</p>
|
||||
<div class="col-span-2 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 max-h-[28rem] overflow-y-auto space-y-2">
|
||||
<p v-if="!sesionActiva" class="text-sm text-gray-500 dark:text-gray-400">Elegí una conversación de la izquierda.</p>
|
||||
<div
|
||||
v-for="m in historial"
|
||||
:key="m.ID"
|
||||
class="max-w-[80%] px-3 py-2 rounded-lg text-sm"
|
||||
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 text-gray-800'"
|
||||
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100'"
|
||||
>
|
||||
{{ m.content }}
|
||||
</div>
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
|
||||
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
|
||||
// Ir directo a configurar el tenant recién creado en vez de dejarlo
|
||||
// perdido en la lista — es lo primero que hay que hacer con él.
|
||||
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
|
||||
try {
|
||||
await api.del(`/app/umind/tenants/${t.ID}`)
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(cargar)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800">Tenants</h1>
|
||||
<button
|
||||
class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg"
|
||||
@click="nuevoTenant"
|
||||
>
|
||||
+ Nuevo tenant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 mb-4">{{ error }}</p>
|
||||
<p v-if="loading" class="text-sm text-gray-500">Cargando...</p>
|
||||
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
<div v-if="tenants.length === 0" class="p-6 text-sm text-gray-500">
|
||||
Todavía no hay tenants. Creá el primero.
|
||||
</div>
|
||||
<div
|
||||
v-for="t in tenants"
|
||||
:key="t.ID"
|
||||
class="p-4 flex items-center justify-between hover:bg-gray-50 cursor-pointer"
|
||||
@click="router.push(`/tenants/${t.ID}`)"
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium text-gray-800">{{ t.nombre }}</span>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
{{ t.dominios_permitidos || 'sin dominios configurados' }}
|
||||
<span
|
||||
class="ml-2 px-1.5 py-0.5 rounded"
|
||||
:class="t.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
>
|
||||
{{ t.activo ? 'activo' : 'inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-brand font-medium">Configurar →</span>
|
||||
<button class="text-gray-500 hover:text-gray-800" @click.stop="editarTenant(t)">Editar</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click.stop="eliminar(t)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal simple de alta/edición -->
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="fixed inset-0 bg-black/30 flex items-center justify-center p-4 z-50"
|
||||
@click.self="showForm = false"
|
||||
>
|
||||
<div class="bg-white rounded-xl p-6 w-full max-w-lg">
|
||||
<h2 class="font-semibold text-gray-800 mb-4">
|
||||
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
|
||||
</h2>
|
||||
<form class="space-y-3" @submit.prevent="guardar">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Nombre</label>
|
||||
<input
|
||||
v-model="form.nombre"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">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 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Config de IA</label>
|
||||
<select
|
||||
v-model="form.ai_config_id"
|
||||
class="w-full border border-gray-300 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">Tono / personalidad</label>
|
||||
<textarea
|
||||
v-model="form.tono"
|
||||
rows="2"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Mensaje de bienvenida</label>
|
||||
<input
|
||||
v-model="form.mensaje_bienvenida"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<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" @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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js}'],
|
||||
darkMode: 'media',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindConexion es una cuenta de correo real (Gmail u Outlook) conectada por
|
||||
// OAuth a un tenant, para que el agente pueda enviar y leer correo en su
|
||||
// nombre (tools enviar_correo/leer_bandeja, ver pkg/services/umind_agent_service.go).
|
||||
// AccessTokenEnc/RefreshTokenEnc viajan cifrados en reposo (ver
|
||||
// pkg/services/umind_secrets.go) — a diferencia del site_key del widget,
|
||||
// estos SÍ son secretos: quien los tenga puede leer/mandar correo como el
|
||||
// dueño de la cuenta.
|
||||
type UmindConexion struct {
|
||||
gorm.Model
|
||||
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
||||
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft
|
||||
Email string `json:"email" gorm:"column:email;size:255"`
|
||||
AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"`
|
||||
RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"`
|
||||
ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"`
|
||||
Scopes string `json:"scopes" gorm:"column:scopes;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (UmindConexion) TableName() string { return "umind_conexiones" }
|
||||
|
||||
func CreateUmindConexion(c *UmindConexion) error {
|
||||
return app.Http.Database.DB.Create(c).Error
|
||||
}
|
||||
|
||||
func GetUmindConexionesByTenant(tenantID uint) ([]UmindConexion, error) {
|
||||
var items []UmindConexion
|
||||
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindConexionByID(id uint) (*UmindConexion, error) {
|
||||
var c UmindConexion
|
||||
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetUmindConexionActiva retorna la primera conexión activa del tenant —
|
||||
// hoy se soporta una sola cuenta de correo conectada por tenant, no una
|
||||
// bandeja por proveedor a la vez.
|
||||
func GetUmindConexionActiva(tenantID uint) (*UmindConexion, error) {
|
||||
var c UmindConexion
|
||||
err := app.Http.Database.DB.Where("tenant_id = ? AND activo = ?", tenantID, true).First(&c).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// DesactivarConexionesDelTenant se llama antes de crear una conexión nueva —
|
||||
// hoy se soporta una sola cuenta de correo activa por tenant a la vez.
|
||||
func DesactivarConexionesDelTenant(tenantID uint) error {
|
||||
return app.Http.Database.DB.Model(&UmindConexion{}).
|
||||
Where("tenant_id = ? AND activo = ?", tenantID, true).
|
||||
Update("activo", false).Error
|
||||
}
|
||||
|
||||
func UpdateUmindConexion(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindConexion{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteUmindConexion(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindConexion{}, id).Error
|
||||
}
|
||||
@@ -82,9 +82,51 @@ func umindTools(tenantID uint) []agentTool {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if conexion, err := models.GetUmindConexionActiva(tenantID); err == nil && conexion != nil {
|
||||
tools = append(tools, umindEmailTools()...)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// umindEmailTools son las tools de correo, disponibles solo cuando el
|
||||
// tenant tiene una cuenta conectada (UmindConexion activa) — nombres
|
||||
// genéricos porque al modelo no le importa si detrás hay Gmail u Outlook.
|
||||
func umindEmailTools() []agentTool {
|
||||
return []agentTool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: agentToolFunc{
|
||||
Name: "enviar_correo",
|
||||
Description: "Envía un correo electrónico desde la cuenta de correo conectada del negocio.",
|
||||
Parameters: agentToolParam{
|
||||
Type: "object",
|
||||
Properties: map[string]agentToolParam{
|
||||
"destinatario": {Type: "string", Description: "Email del destinatario"},
|
||||
"asunto": {Type: "string", Description: "Asunto del correo"},
|
||||
"cuerpo": {Type: "string", Description: "Cuerpo del correo en texto plano"},
|
||||
},
|
||||
Required: []string{"destinatario", "asunto", "cuerpo"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function",
|
||||
Function: agentToolFunc{
|
||||
Name: "leer_bandeja",
|
||||
Description: "Busca correos recibidos en la bandeja conectada del negocio (ej. revisar si llegó un comprobante o la respuesta de un cliente).",
|
||||
Parameters: agentToolParam{
|
||||
Type: "object",
|
||||
Properties: map[string]agentToolParam{
|
||||
"consulta": {Type: "string", Description: "Qué buscar: remitente, palabras clave del asunto o del cuerpo"},
|
||||
},
|
||||
Required: []string{"consulta"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// executeUmindTool ejecuta buscar_conocimiento (RAG interno) o, si el nombre
|
||||
// no matchea, busca una UmindHerramienta custom del tenant y hace el POST al
|
||||
// webhook configurado. Devuelve el resultado ya serializado, en el mismo
|
||||
@@ -111,6 +153,10 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
|
||||
return string(b)
|
||||
}
|
||||
|
||||
if name == "enviar_correo" || name == "leer_bandeja" {
|
||||
return executeUmindEmailTool(tenantID, name, args)
|
||||
}
|
||||
|
||||
herramienta, err := models.GetUmindHerramientaByNombre(tenantID, name)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error": "herramienta desconocida: %s"}`, name)
|
||||
@@ -131,6 +177,60 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
|
||||
return resultado
|
||||
}
|
||||
|
||||
// executeUmindEmailTool despacha enviar_correo/leer_bandeja a Gmail o
|
||||
// Microsoft Graph según el proveedor de la conexión activa del tenant,
|
||||
// refrescando el token primero si hace falta.
|
||||
func executeUmindEmailTool(tenantID uint, name string, args map[string]interface{}) string {
|
||||
conexion, err := models.GetUmindConexionActiva(tenantID)
|
||||
if err != nil {
|
||||
return `{"error": "no hay ninguna cuenta de correo conectada"}`
|
||||
}
|
||||
if err := RefrescarSiVence(conexion); err != nil {
|
||||
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
|
||||
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "enviar_correo":
|
||||
destinatario, _ := args["destinatario"].(string)
|
||||
asunto, _ := args["asunto"].(string)
|
||||
cuerpo, _ := args["cuerpo"].(string)
|
||||
if strings.TrimSpace(destinatario) == "" || strings.TrimSpace(cuerpo) == "" {
|
||||
return `{"error": "destinatario y cuerpo son requeridos"}`
|
||||
}
|
||||
var envErr error
|
||||
if conexion.Proveedor == UmindOAuthGoogle {
|
||||
envErr = EnviarCorreoGoogle(conexion, destinatario, asunto, cuerpo)
|
||||
} else {
|
||||
envErr = EnviarCorreoMicrosoft(conexion, destinatario, asunto, cuerpo)
|
||||
}
|
||||
if envErr != nil {
|
||||
log.Printf("[UMIND] Error enviando correo (tenant %d): %v", tenantID, envErr)
|
||||
return `{"error": "no se pudo enviar el correo"}`
|
||||
}
|
||||
return `{"ok": true}`
|
||||
|
||||
case "leer_bandeja":
|
||||
consulta, _ := args["consulta"].(string)
|
||||
var resultados []CorreoResumen
|
||||
var lecErr error
|
||||
if conexion.Proveedor == UmindOAuthGoogle {
|
||||
resultados, lecErr = LeerBandejaGoogle(conexion, consulta, 5)
|
||||
} else {
|
||||
resultados, lecErr = LeerBandejaMicrosoft(conexion, consulta, 5)
|
||||
}
|
||||
if lecErr != nil {
|
||||
log.Printf("[UMIND] Error leyendo bandeja (tenant %d): %v", tenantID, lecErr)
|
||||
return `{"error": "no se pudo leer la bandeja"}`
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{"resultados": resultados})
|
||||
return string(b)
|
||||
|
||||
default:
|
||||
return `{"error": "herramienta desconocida"}`
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessWidgetMessage procesa un mensaje del widget de uMind y devuelve la
|
||||
// respuesta del agente. Es el equivalente de ProcessAgentMessage pero
|
||||
// multi-tenant y con un toolset acotado a RAG (sin herramientas internas).
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// EnviarCorreoGoogle manda un correo de texto plano vía Gmail API en nombre
|
||||
// de la cuenta conectada. Asume que conexion ya pasó por RefrescarSiVence.
|
||||
func EnviarCorreoGoogle(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
mime := fmt.Sprintf("To: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\n%s",
|
||||
destinatario, asunto, cuerpo)
|
||||
raw := base64.RawURLEncoding.EncodeToString([]byte(mime))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"raw": raw})
|
||||
req, err := http.NewRequest(http.MethodPost, "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo contactar Gmail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LeerBandejaGoogle busca mensajes en Gmail (sintaxis de búsqueda de Gmail,
|
||||
// ej: "from:cliente@ejemplo.com") y devuelve un resumen liviano de cada uno.
|
||||
func LeerBandejaGoogle(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
|
||||
if limite <= 0 || limite > 10 {
|
||||
limite = 10
|
||||
}
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
listURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?q=%s&maxResults=%d",
|
||||
url.QueryEscape(consulta), limite)
|
||||
var lista struct {
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := gmailGetJSON(listURL, accessToken, &lista); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultados := make([]CorreoResumen, 0, len(lista.Messages))
|
||||
for _, m := range lista.Messages {
|
||||
detalleURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date", m.ID)
|
||||
var msg struct {
|
||||
Snippet string `json:"snippet"`
|
||||
Payload struct {
|
||||
Headers []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
} `json:"headers"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := gmailGetJSON(detalleURL, accessToken, &msg); err != nil {
|
||||
continue // un mensaje individual que falla no debe tirar abajo toda la búsqueda
|
||||
}
|
||||
r := CorreoResumen{Extracto: msg.Snippet}
|
||||
for _, h := range msg.Payload.Headers {
|
||||
switch h.Name {
|
||||
case "From":
|
||||
r.De = h.Value
|
||||
case "Subject":
|
||||
r.Asunto = h.Value
|
||||
case "Date":
|
||||
r.Fecha = h.Value
|
||||
}
|
||||
}
|
||||
resultados = append(resultados, r)
|
||||
}
|
||||
return resultados, nil
|
||||
}
|
||||
|
||||
func gmailGetJSON(url, accessToken string, out interface{}) error {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo contactar Gmail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// EnviarCorreoMicrosoft manda un correo de texto plano vía Microsoft Graph
|
||||
// (POST /me/sendMail) en nombre de la cuenta conectada.
|
||||
func EnviarCorreoMicrosoft(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"subject": asunto,
|
||||
"body": map[string]string{"contentType": "Text", "content": cuerpo},
|
||||
"toRecipients": []map[string]interface{}{
|
||||
{"emailAddress": map[string]string{"address": destinatario}},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, "https://graph.microsoft.com/v1.0/me/sendMail", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LeerBandejaMicrosoft busca mensajes en la bandeja vía Microsoft Graph
|
||||
// ($search sobre asunto/cuerpo/remitente) y devuelve un resumen liviano.
|
||||
func LeerBandejaMicrosoft(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
|
||||
if limite <= 0 || limite > 10 {
|
||||
limite = 10
|
||||
}
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
q := fmt.Sprintf("https://graph.microsoft.com/v1.0/me/messages?$search=%s&$top=%d&$select=from,subject,receivedDateTime,bodyPreview",
|
||||
url.QueryEscape(`"`+consulta+`"`), limite)
|
||||
req, err := http.NewRequest(http.MethodGet, q, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
// $search requiere este header ("eventual consistency") en Microsoft Graph.
|
||||
req.Header.Set("ConsistencyLevel", "eventual")
|
||||
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Value []struct {
|
||||
From struct {
|
||||
EmailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
} `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedDateTime string `json:"receivedDateTime"`
|
||||
BodyPreview string `json:"bodyPreview"`
|
||||
} `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultados := make([]CorreoResumen, 0, len(out.Value))
|
||||
for _, m := range out.Value {
|
||||
resultados = append(resultados, CorreoResumen{
|
||||
De: m.From.EmailAddress.Address,
|
||||
Asunto: m.Subject,
|
||||
Fecha: m.ReceivedDateTime,
|
||||
Extracto: m.BodyPreview,
|
||||
})
|
||||
}
|
||||
return resultados, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"golang.org/x/oauth2/microsoft"
|
||||
)
|
||||
|
||||
const (
|
||||
UmindOAuthGoogle = "google"
|
||||
UmindOAuthMicrosoft = "microsoft"
|
||||
)
|
||||
|
||||
// CorreoResumen es el formato común en el que enviar_correo/leer_bandeja
|
||||
// devuelven un mensaje al agente, sin importar el proveedor real detrás.
|
||||
type CorreoResumen struct {
|
||||
De string `json:"de"`
|
||||
Asunto string `json:"asunto"`
|
||||
Fecha string `json:"fecha"`
|
||||
Extracto string `json:"extracto"`
|
||||
}
|
||||
|
||||
var umindOAuthHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// firmarState / verificarState arman el parámetro state del flujo OAuth
|
||||
// autoverificable (tenantID + nonce + HMAC con APP_KEY) — evita necesitar una
|
||||
// tabla de "estados pendientes": si la firma es válida, el state no fue
|
||||
// alterado desde que lo generamos nosotros.
|
||||
func firmarState(tenantID uint) (string, error) {
|
||||
nonce := make([]byte, 8)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := fmt.Sprintf("%d.%s", tenantID, hex.EncodeToString(nonce))
|
||||
mac := hmac.New(sha256.New, []byte(app.Http.Server.Key))
|
||||
mac.Write([]byte(payload))
|
||||
return payload + "." + hex.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func verificarState(state string) (uint, error) {
|
||||
partes := strings.Split(state, ".")
|
||||
if len(partes) != 3 {
|
||||
return 0, fmt.Errorf("formato de state inválido")
|
||||
}
|
||||
payload := partes[0] + "." + partes[1]
|
||||
mac := hmac.New(sha256.New, []byte(app.Http.Server.Key))
|
||||
mac.Write([]byte(payload))
|
||||
esperada := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(esperada), []byte(partes[2])) {
|
||||
return 0, fmt.Errorf("firma de state inválida")
|
||||
}
|
||||
tenantID, err := strconv.ParseUint(partes[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("tenant_id inválido en state: %w", err)
|
||||
}
|
||||
return uint(tenantID), nil
|
||||
}
|
||||
|
||||
func redirectURLOAuth(proveedor string) string {
|
||||
return strings.TrimRight(app.Http.Server.Url, "/") + "/app/umind/conexiones/callback/" + proveedor
|
||||
}
|
||||
|
||||
func oauth2ConfigPara(proveedor string) (*oauth2.Config, error) {
|
||||
switch proveedor {
|
||||
case UmindOAuthGoogle:
|
||||
if app.Http.OAuth.GoogleClientID == "" || app.Http.OAuth.GoogleClientSecret == "" {
|
||||
return nil, fmt.Errorf("Google OAuth no está configurado en el servidor (GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET)")
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: app.Http.OAuth.GoogleClientID,
|
||||
ClientSecret: app.Http.OAuth.GoogleClientSecret,
|
||||
RedirectURL: redirectURLOAuth(proveedor),
|
||||
Scopes: []string{
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
},
|
||||
Endpoint: google.Endpoint,
|
||||
}, nil
|
||||
case UmindOAuthMicrosoft:
|
||||
if app.Http.OAuth.MSClientID == "" || app.Http.OAuth.MSClientSecret == "" {
|
||||
return nil, fmt.Errorf("Microsoft OAuth no está configurado en el servidor (MS_OAUTH_CLIENT_ID/MS_OAUTH_CLIENT_SECRET)")
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: app.Http.OAuth.MSClientID,
|
||||
ClientSecret: app.Http.OAuth.MSClientSecret,
|
||||
RedirectURL: redirectURLOAuth(proveedor),
|
||||
Scopes: []string{
|
||||
"offline_access", "openid", "email",
|
||||
"https://graph.microsoft.com/Mail.Send",
|
||||
"https://graph.microsoft.com/Mail.Read",
|
||||
},
|
||||
Endpoint: microsoft.AzureADEndpoint("common"),
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("proveedor desconocido: %s", proveedor)
|
||||
}
|
||||
}
|
||||
|
||||
// IniciarConexionOAuth arma la URL de autorización a la que hay que
|
||||
// redirigir al staff. prompt=consent en Google fuerza a que siempre vuelva
|
||||
// un refresh_token (si no, Google solo lo manda la primera vez que el
|
||||
// usuario autoriza la app, nunca más).
|
||||
func IniciarConexionOAuth(proveedor string, tenantID uint) (string, error) {
|
||||
cfg, err := oauth2ConfigPara(proveedor)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
state, err := firmarState(tenantID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
|
||||
if proveedor == UmindOAuthGoogle {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
}
|
||||
return cfg.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// CompletarConexionOAuth intercambia el code por tokens, identifica la
|
||||
// cuenta conectada y guarda la conexión cifrada. Reemplaza cualquier
|
||||
// conexión previa activa del tenant (una cuenta de correo a la vez).
|
||||
func CompletarConexionOAuth(proveedor, code, state string) (*models.UmindConexion, error) {
|
||||
tenantID, err := verificarState(state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("state inválido: %w", err)
|
||||
}
|
||||
cfg, err := oauth2ConfigPara(proveedor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := cfg.Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo intercambiar el código de autorización: %w", err)
|
||||
}
|
||||
if tok.RefreshToken == "" {
|
||||
return nil, fmt.Errorf("el proveedor no devolvió un refresh_token — revocá el acceso de esta app en tu cuenta y volvé a conectar")
|
||||
}
|
||||
|
||||
email, err := obtenerEmailDeCuenta(proveedor, tok.AccessToken)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND_OAUTH] no se pudo obtener el email de la cuenta conectada (%s): %v", proveedor, err)
|
||||
}
|
||||
|
||||
accessEnc, err := CifrarSecretoUmind(tok.AccessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refreshEnc, err := CifrarSecretoUmind(tok.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := models.DesactivarConexionesDelTenant(tenantID); err != nil {
|
||||
log.Printf("[UMIND_OAUTH] no se pudieron desactivar conexiones previas del tenant %d: %v", tenantID, err)
|
||||
}
|
||||
conexion := &models.UmindConexion{
|
||||
TenantID: tenantID,
|
||||
Proveedor: proveedor,
|
||||
Email: email,
|
||||
AccessTokenEnc: accessEnc,
|
||||
RefreshTokenEnc: refreshEnc,
|
||||
ExpiraEn: tok.Expiry,
|
||||
Scopes: strings.Join(cfg.Scopes, " "),
|
||||
Activo: true,
|
||||
}
|
||||
if err := models.CreateUmindConexion(conexion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conexion, nil
|
||||
}
|
||||
|
||||
func obtenerEmailDeCuenta(proveedor, accessToken string) (string, error) {
|
||||
var url string
|
||||
switch proveedor {
|
||||
case UmindOAuthGoogle:
|
||||
url = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
case UmindOAuthMicrosoft:
|
||||
url = "https://graph.microsoft.com/v1.0/me"
|
||||
default:
|
||||
return "", fmt.Errorf("proveedor desconocido: %s", proveedor)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct {
|
||||
Email string `json:"email"` // Google
|
||||
Mail string `json:"mail"` // Microsoft
|
||||
UserPrincipalName string `json:"userPrincipalName"` // Microsoft, fallback si "mail" viene vacío
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Email != "" {
|
||||
return out.Email, nil
|
||||
}
|
||||
if out.Mail != "" {
|
||||
return out.Mail, nil
|
||||
}
|
||||
return out.UserPrincipalName, nil
|
||||
}
|
||||
|
||||
// RefrescarSiVence renueva el access token si está vencido o a menos de 2
|
||||
// minutos de vencer, y persiste el nuevo valor cifrado. Se llama justo antes
|
||||
// de usar la conexión (enviar/leer correo), no por un cron aparte — ver nota
|
||||
// de alcance en el plan: si en la práctica hace falta refresco proactivo, se
|
||||
// agrega por pkg/services/cron_service.go sin tocar esta función.
|
||||
func RefrescarSiVence(conexion *models.UmindConexion) error {
|
||||
if time.Now().Add(2 * time.Minute).Before(conexion.ExpiraEn) {
|
||||
return nil
|
||||
}
|
||||
cfg, err := oauth2ConfigPara(conexion.Proveedor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refreshToken, err := DescifrarSecretoUmind(conexion.RefreshTokenEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descifrar el refresh_token: %w", err)
|
||||
}
|
||||
|
||||
nuevo, err := cfg.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken}).Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo refrescar el token: %w", err)
|
||||
}
|
||||
|
||||
accessEnc, err := CifrarSecretoUmind(nuevo.AccessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{"access_token_enc": accessEnc, "expira_en": nuevo.Expiry}
|
||||
if nuevo.RefreshToken != "" && nuevo.RefreshToken != refreshToken {
|
||||
if refreshEnc, err := CifrarSecretoUmind(nuevo.RefreshToken); err == nil {
|
||||
updates["refresh_token_enc"] = refreshEnc
|
||||
conexion.RefreshTokenEnc = refreshEnc
|
||||
}
|
||||
}
|
||||
if err := models.UpdateUmindConexion(conexion.ID, updates); err != nil {
|
||||
log.Printf("[UMIND_OAUTH] no se pudo persistir el refresh del token (conexión %d): %v", conexion.ID, err)
|
||||
}
|
||||
conexion.AccessTokenEnc = accessEnc
|
||||
conexion.ExpiraEn = nuevo.Expiry
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/config"
|
||||
)
|
||||
|
||||
func TestFirmarYVerificarState(t *testing.T) {
|
||||
app.Http = &config.AppConfig{Server: config.ServerConfig{Key: "clave-de-prueba-no-real"}}
|
||||
|
||||
state, err := firmarState(42)
|
||||
if err != nil {
|
||||
t.Fatalf("firmarState: %v", err)
|
||||
}
|
||||
tenantID, err := verificarState(state)
|
||||
if err != nil {
|
||||
t.Fatalf("verificarState de un state válido falló: %v", err)
|
||||
}
|
||||
if tenantID != 42 {
|
||||
t.Errorf("tenantID = %d, esperaba 42", tenantID)
|
||||
}
|
||||
|
||||
if _, err := verificarState(state + "x"); err == nil {
|
||||
t.Error("un state alterado fue aceptado")
|
||||
}
|
||||
if _, err := verificarState("formato.invalido"); err == nil {
|
||||
t.Error("un state con formato inválido fue aceptado")
|
||||
}
|
||||
if _, err := verificarState("noesnumero.aabbcc.deadbeef"); err == nil {
|
||||
t.Error("un tenant_id no numérico fue aceptado")
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@
|
||||
<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-DopCJrBT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DniozBCm.css">
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-D6shNZ1Q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-N2c9c8FD.css">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
// GetUmindConexionesHandler lista las cuentas de correo conectadas de un
|
||||
// tenant, sin exponer los tokens (ni cifrados ni en claro).
|
||||
func GetUmindConexionesHandler(c *fiber.Ctx) error {
|
||||
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
||||
if err != nil || tenantID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
||||
}
|
||||
items, err := models.GetUmindConexionesByTenant(uint(tenantID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
out := make([]fiber.Map, len(items))
|
||||
for i, cx := range items {
|
||||
out[i] = fiber.Map{
|
||||
"ID": cx.ID, "tenant_id": cx.TenantID, "proveedor": cx.Proveedor,
|
||||
"email": cx.Email, "activo": cx.Activo, "expira_en": cx.ExpiraEn,
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{"items": out})
|
||||
}
|
||||
|
||||
// UmindConectarHandler redirige al staff a la pantalla de consentimiento de
|
||||
// Google/Microsoft. Ruta: GET /app/umind/conexiones/conectar?tenant_id=&proveedor=
|
||||
func UmindConectarHandler(c *fiber.Ctx) error {
|
||||
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
||||
if err != nil || tenantID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
||||
}
|
||||
proveedor := c.Query("proveedor")
|
||||
if _, err := models.GetUmindTenantByID(uint(tenantID)); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
|
||||
}
|
||||
|
||||
url, err := services.IniciarConexionOAuth(proveedor, uint(tenantID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Redirect(url, fiber.StatusFound)
|
||||
}
|
||||
|
||||
// UmindOAuthCallbackHandler recibe la vuelta de Google/Microsoft, intercambia
|
||||
// el code y redirige al staff de vuelta a la SPA.
|
||||
// Ruta: GET /app/umind/conexiones/callback/:proveedor
|
||||
func UmindOAuthCallbackHandler(c *fiber.Ctx) error {
|
||||
proveedor := c.Params("proveedor")
|
||||
if errParam := c.Query("error"); errParam != "" {
|
||||
return c.Redirect(fmt.Sprintf("/orchestrator/?oauth_error=%s", errParam), fiber.StatusFound)
|
||||
}
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
if code == "" || state == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "callback inválido"})
|
||||
}
|
||||
|
||||
conexion, err := services.CompletarConexionOAuth(proveedor, code, state)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND_OAUTH] error completando conexión (%s): %v", proveedor, err)
|
||||
return c.Redirect("/orchestrator/?oauth_error=1", fiber.StatusFound)
|
||||
}
|
||||
return c.Redirect(fmt.Sprintf("/orchestrator/tenants/%d?tab=conexiones", conexion.TenantID), fiber.StatusFound)
|
||||
}
|
||||
|
||||
func DeleteUmindConexionHandler(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := models.DeleteUmindConexion(uint(id)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
@@ -374,6 +374,10 @@ func UserRoutes(app fiber.Router) {
|
||||
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)
|
||||
|
||||
// ─── uMind Orquestador (SPA Vue) ────────────────────────────────────────────
|
||||
// Estáticos reales (JS/CSS del build) ya los sirve el Static("/") general
|
||||
|
||||
Reference in New Issue
Block a user