diff --git a/src/components/mika/integrations/DisconnectMCPDialog.tsx b/src/components/mika/integrations/DisconnectMCPDialog.tsx
new file mode 100644
index 0000000..8b94891
--- /dev/null
+++ b/src/components/mika/integrations/DisconnectMCPDialog.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { AlertTriangle } from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useDependentJobsForMcp } from "@/hooks/use-integrations";
+import { invokeFunction } from "@/lib/invoke-function";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ integrationId: string;
+ mcpSlug: string;
+ mcpName: string;
+}
+
+export function DisconnectMCPDialog({
+ open,
+ onOpenChange,
+ integrationId,
+ mcpSlug,
+ mcpName,
+}: Props) {
+ const { data: dependentJobs = [], isLoading } = useDependentJobsForMcp(
+ open ? mcpSlug : undefined,
+ );
+ const queryClient = useQueryClient();
+ const [submitting, setSubmitting] = useState(false);
+
+ const activeJobs = dependentJobs.filter((j) => j.status === "active");
+ const hasActiveJobs = activeJobs.length > 0;
+
+ async function handleDisconnect() {
+ setSubmitting(true);
+ const { error } = await invokeFunction("disconnect-integration", {
+ integration_id: integrationId,
+ });
+ setSubmitting(false);
+ if (error) {
+ toast.error(error.message);
+ return;
+ }
+ toast.success(`${mcpName} desconectado.`);
+ queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
+ queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] });
+ onOpenChange(false);
+ }
+
+ return (
+
+
+
+ Desconectar {mcpName}?
+
+
+
+ A conexão será removida e os tokens serão revogados. Você pode reconectar
+ a qualquer momento.
+
+
+ {isLoading && (
+
+ Verificando automações dependentes...
+
+ )}
+
+ {!isLoading && hasActiveJobs && (
+
+
+
+
+
+ {activeJobs.length}{" "}
+ {activeJobs.length === 1 ? "automação ativa" : "automações ativas"}{" "}
+ depende{activeJobs.length === 1 ? "" : "m"} desta integração:
+
+
+ {activeJobs.slice(0, 5).map((j) => (
+ - {j.name}
+ ))}
+ {activeJobs.length > 5 && (
+ - e mais {activeJobs.length - 5}...
+ )}
+
+
+ Pause ou exclua essas automações antes de desconectar.
+
+
+
+
+ )}
+
+
+
+
+ Cancelar
+ {
+ e.preventDefault();
+ handleDisconnect();
+ }}
+ disabled={submitting || isLoading || hasActiveJobs}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {submitting ? "Desconectando..." : "Desconectar"}
+
+
+
+
+ );
+}
diff --git a/src/components/mika/integrations/IntegrationCard.tsx b/src/components/mika/integrations/IntegrationCard.tsx
new file mode 100644
index 0000000..94be0d0
--- /dev/null
+++ b/src/components/mika/integrations/IntegrationCard.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { Link } from "@tanstack/react-router";
+import { Lock, AlertCircle, CheckCircle2, Plug } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import type { IntegrationCardState } from "@/hooks/use-integrations";
+import { toast } from "sonner";
+import { useState } from "react";
+import { invokeFunction } from "@/lib/invoke-function";
+
+interface Props {
+ state: IntegrationCardState;
+ agentReady: boolean;
+}
+
+export function IntegrationCard({ state, agentReady }: Props) {
+ const { mcp } = state;
+ const [connecting, setConnecting] = useState(false);
+
+ async function handleConnect() {
+ if (!agentReady) {
+ toast.error("Seu agente ainda está sendo provisionado.");
+ return;
+ }
+ setConnecting(true);
+ const { data, error } = await invokeFunction<{ authorize_url: string }>(
+ "oauth-start",
+ { mcp_slug: mcp.slug },
+ );
+ setConnecting(false);
+ if (error || !data?.authorize_url) {
+ toast.error(error?.message ?? "Não foi possível iniciar a conexão.");
+ return;
+ }
+ window.location.href = data.authorize_url;
+ }
+
+ return (
+
+
+

+
+
+
{mcp.name}
+ {state.kind === "connected" && (
+
+ Conectado
+
+ )}
+ {state.kind === "error" && (
+
+
+ {state.integration.status === "expired"
+ ? "Expirado"
+ : state.integration.status === "revoked"
+ ? "Revogado"
+ : "Erro"}
+
+ )}
+ {state.kind === "locked" && (
+
+ Bloqueado
+
+ )}
+
+
+ {mcp.description}
+
+
+
+
+ {state.kind === "connected" && state.integration.connected_account_email && (
+
+ Conta: {state.integration.connected_account_email}
+
+ )}
+
+ {state.kind === "error" && state.integration.error_message && (
+
+ {state.integration.error_message}
+
+ )}
+
+ {state.kind === "locked" && (
+
+ Disponível nos planos: {state.mcp.available_in_plans.join(", ") || "—"}
+
+ )}
+
+
+ {state.kind === "available" && (
+
+ )}
+ {state.kind === "locked" && (
+
+ )}
+ {(state.kind === "connected" || state.kind === "error") && (
+
+ )}
+
+
+ );
+}
diff --git a/src/routes/painel.integracoes.$slug.tsx b/src/routes/painel.integracoes.$slug.tsx
new file mode 100644
index 0000000..0687ae2
--- /dev/null
+++ b/src/routes/painel.integracoes.$slug.tsx
@@ -0,0 +1,312 @@
+"use client";
+
+import { useState } from "react";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ ArrowLeft,
+ RefreshCw,
+ CheckCircle2,
+ AlertCircle,
+ Clock,
+ Unplug,
+ Activity,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ useAvailableMcps,
+ useUserIntegrations,
+ useDependentJobsForMcp,
+} from "@/hooks/use-integrations";
+import { invokeFunction } from "@/lib/invoke-function";
+import { DisconnectMCPDialog } from "@/components/mika/integrations/DisconnectMCPDialog";
+
+export const Route = createFileRoute("/painel/integracoes/$slug")({
+ component: IntegrationDetailPage,
+});
+
+function formatPtBR(date: string | null): string {
+ if (!date) return "—";
+ try {
+ return new Intl.DateTimeFormat("pt-BR", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(date));
+ } catch {
+ return "—";
+ }
+}
+
+function IntegrationDetailPage() {
+ const { slug } = Route.useParams();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { data: mcps = [], isLoading: mcpsLoading } = useAvailableMcps();
+ const { data: integs = [], isLoading: integsLoading } = useUserIntegrations();
+ const { data: dependentJobs = [] } = useDependentJobsForMcp(slug);
+
+ const [testing, setTesting] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const [disconnectOpen, setDisconnectOpen] = useState(false);
+
+ const mcp = mcps.find((m) => m.slug === slug);
+ const integration = mcp ? integs.find((i) => i.mcp_id === mcp.id) : null;
+
+ if (mcpsLoading || integsLoading) {
+ return (
+
+ );
+ }
+
+ if (!mcp) {
+ return (
+
+
+
+
Integração não encontrada.
+
+
+ );
+ }
+
+ if (!integration) {
+ return (
+
+
+
+
+ Você ainda não conectou {mcp.name}.
+
+
+
+
+ );
+ }
+
+ async function handleTest() {
+ setTesting(true);
+ const { data, error } = await invokeFunction<{ ok: boolean; account?: string }>(
+ "test-integration",
+ { integration_id: integration!.id },
+ );
+ setTesting(false);
+ if (error) {
+ toast.error(error.message);
+ } else if (data?.ok) {
+ toast.success("Conexão funcionando perfeitamente.");
+ } else {
+ toast.warning("Conexão respondeu, mas com aviso. Verifique status.");
+ }
+ queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
+ }
+
+ async function handleRefresh() {
+ setRefreshing(true);
+ const { error } = await invokeFunction("refresh-integration-token", {
+ integration_id: integration!.id,
+ });
+ setRefreshing(false);
+ if (error) {
+ toast.error(error.message);
+ } else {
+ toast.success("Token renovado.");
+ }
+ queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
+ }
+
+ const status = integration.status;
+ const statusBadge =
+ status === "active" ? (
+
+ Ativo
+
+ ) : status === "expired" ? (
+
+ Expirado
+
+ ) : status === "revoked" ? (
+
+ Revogado
+
+ ) : (
+
+ Erro
+
+ );
+
+ return (
+
+
+
+
+
+

+
+
+
{mcp.name}
+ {statusBadge}
+
+
{mcp.description}
+
+
+
+ {integration.error_message && (
+
+ {integration.error_message}
+
+ )}
+
+
+
+
+
Conta conectada
+
+
+
- Email
+ - {integration.connected_account_email ?? "—"}
+
+ {integration.connected_account_name && (
+
+
- Nome
+ - {integration.connected_account_name}
+
+ )}
+
+
- Conectado em
+ - {formatPtBR(integration.created_at)}
+
+
+
- Última renovação
+ - {formatPtBR(integration.last_refreshed_at)}
+
+ {integration.token_expires_at && (
+
+
- Token expira em
+ - {formatPtBR(integration.token_expires_at)}
+
+ )}
+
+
+
+
+
Permissões concedidas
+ {integration.granted_scopes.length === 0 ? (
+
Nenhum escopo registrado.
+ ) : (
+
+ {integration.granted_scopes.map((s) => (
+ -
+ {s}
+
+ ))}
+
+ )}
+
+
+
+ {dependentJobs.length > 0 && (
+
+
+ Automações que usam esta integração
+
+
+ {dependentJobs.map((j) => (
+ -
+ {j.name}
+
+ {j.status}
+
+
+ ))}
+
+
+ )}
+
+
+
+ {mcp.supports_refresh_token && (
+
+ )}
+ {(status === "expired" || status === "revoked" || status === "error") && (
+
+ )}
+
+
+
+
{
+ setDisconnectOpen(o);
+ if (!o) {
+ // se desconectou, volta para a lista
+ queryClient
+ .invalidateQueries({ queryKey: ["user-integrations"] })
+ .then(() => {
+ const stillConnected = integs.some((i) => i.id === integration.id);
+ if (!stillConnected) {
+ navigate({ to: "/painel/integracoes" });
+ }
+ });
+ }
+ }}
+ integrationId={integration.id}
+ mcpSlug={mcp.slug}
+ mcpName={mcp.name}
+ />
+
+ );
+}
diff --git a/src/routes/painel.integracoes.index.tsx b/src/routes/painel.integracoes.index.tsx
new file mode 100644
index 0000000..13d79c3
--- /dev/null
+++ b/src/routes/painel.integracoes.index.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { createFileRoute } from "@tanstack/react-router";
+import { Plug } from "lucide-react";
+import { useIntegrationCards } from "@/hooks/use-integrations";
+import { useAgentInstance } from "@/hooks/use-agent-instance";
+import { IntegrationCard } from "@/components/mika/integrations/IntegrationCard";
+
+export const Route = createFileRoute("/painel/integracoes/")({
+ component: IntegracoesPage,
+});
+
+function IntegracoesPage() {
+ const { cards, isLoading, error, limit } = useIntegrationCards();
+ const { data: agent } = useAgentInstance();
+ const agentReady = agent?.status === "active" || agent?.status === "ready";
+
+ return (
+
+
+
+
+ Integrações
+
+
+ Conecte ferramentas externas ao seu agente Mika.
+
+
+ {limit && limit.max_integrations !== null && (
+
+
+ {limit.current_integrations_count ?? 0}
+
+ {" / "}
+ {limit.max_integrations} integrações
+
+ )}
+
+
+ {error && (
+
+ Erro ao carregar integrações: {error.message}
+
+ )}
+
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : cards.length === 0 ? (
+
+
Nenhuma integração disponível no momento.
+
+ ) : (
+
+ {cards.map((card) => (
+
+ ))}
+
+ )}
+
+ );
+}