Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-18 02:06:09 +00:00
parent c0c98b5c6d
commit 3befa7e6e4
4 changed files with 620 additions and 0 deletions

View file

@ -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 (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
);
}
if (!mcp) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link to="/painel/integracoes">
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
</Link>
</Button>
<div className="rounded-xl border border-border bg-card p-8 text-center">
<p className="text-muted-foreground">Integração não encontrada.</p>
</div>
</div>
);
}
if (!integration) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link to="/painel/integracoes">
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
</Link>
</Button>
<div className="rounded-xl border border-border bg-card p-8 text-center">
<p className="text-muted-foreground">
Você ainda não conectou {mcp.name}.
</p>
<Button asChild className="mt-4">
<Link to="/painel/integracoes">Conectar</Link>
</Button>
</div>
</div>
);
}
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" ? (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> Ativo
</Badge>
) : status === "expired" ? (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" /> Expirado
</Badge>
) : status === "revoked" ? (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" /> Revogado
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" /> Erro
</Badge>
);
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link to="/painel/integracoes">
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
</Link>
</Button>
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
<div className="flex items-start gap-4">
<img
src={mcp.icon_url}
alt=""
className="h-14 w-14 rounded-lg object-contain bg-muted/40 p-2"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold">{mcp.name}</h1>
{statusBadge}
</div>
<p className="text-sm text-muted-foreground mt-1">{mcp.description}</p>
</div>
</div>
{integration.error_message && (
<div className="mt-4 rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{integration.error_message}
</div>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-border bg-card p-5 shadow-soft space-y-3">
<h2 className="font-semibold">Conta conectada</h2>
<dl className="text-sm space-y-2">
<div>
<dt className="text-muted-foreground">Email</dt>
<dd className="font-mono">{integration.connected_account_email ?? "—"}</dd>
</div>
{integration.connected_account_name && (
<div>
<dt className="text-muted-foreground">Nome</dt>
<dd>{integration.connected_account_name}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground">Conectado em</dt>
<dd>{formatPtBR(integration.created_at)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Última renovação</dt>
<dd>{formatPtBR(integration.last_refreshed_at)}</dd>
</div>
{integration.token_expires_at && (
<div>
<dt className="text-muted-foreground">Token expira em</dt>
<dd>{formatPtBR(integration.token_expires_at)}</dd>
</div>
)}
</dl>
</div>
<div className="rounded-xl border border-border bg-card p-5 shadow-soft space-y-3">
<h2 className="font-semibold">Permissões concedidas</h2>
{integration.granted_scopes.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhum escopo registrado.</p>
) : (
<ul className="text-xs font-mono space-y-1 max-h-48 overflow-auto">
{integration.granted_scopes.map((s) => (
<li key={s} className="text-muted-foreground break-all">
{s}
</li>
))}
</ul>
)}
</div>
</div>
{dependentJobs.length > 0 && (
<div className="rounded-xl border border-border bg-card p-5 shadow-soft">
<h2 className="font-semibold flex items-center gap-2 mb-3">
<Activity className="h-4 w-4" /> Automações que usam esta integração
</h2>
<ul className="space-y-2">
{dependentJobs.map((j) => (
<li
key={j.id}
className="flex items-center justify-between text-sm border-b border-border last:border-0 pb-2 last:pb-0"
>
<span>{j.name}</span>
<Badge variant={j.status === "active" ? "success" : "secondary"}>
{j.status}
</Badge>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button onClick={handleTest} disabled={testing} variant="outline">
<Activity className="h-4 w-4 mr-2" />
{testing ? "Testando..." : "Testar conexão"}
</Button>
{mcp.supports_refresh_token && (
<Button onClick={handleRefresh} disabled={refreshing} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
{refreshing ? "Renovando..." : "Renovar token"}
</Button>
)}
{(status === "expired" || status === "revoked" || status === "error") && (
<Button
onClick={async () => {
const { data, error } = await invokeFunction<{ authorize_url: string }>(
"oauth-start",
{ mcp_slug: mcp.slug },
);
if (error || !data?.authorize_url) {
toast.error(error?.message ?? "Falha ao iniciar reconexão.");
return;
}
window.location.href = data.authorize_url;
}}
>
Reconectar
</Button>
)}
<Button
variant="destructive"
onClick={() => setDisconnectOpen(true)}
className="ml-auto"
>
<Unplug className="h-4 w-4 mr-2" /> Desconectar
</Button>
</div>
<DisconnectMCPDialog
open={disconnectOpen}
onOpenChange={(o) => {
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}
/>
</div>
);
}

View file

@ -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 (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Plug className="h-6 w-6 text-primary" /> Integrações
</h1>
<p className="text-sm text-muted-foreground mt-1">
Conecte ferramentas externas ao seu agente Mika.
</p>
</div>
{limit && limit.max_integrations !== null && (
<div className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">
{limit.current_integrations_count ?? 0}
</span>
{" / "}
{limit.max_integrations} integrações
</div>
)}
</div>
{error && (
<div className="rounded-md border border-destructive bg-destructive/10 p-4 text-sm text-destructive">
Erro ao carregar integrações: {error.message}
</div>
)}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-44 rounded-xl border border-border bg-muted/30 animate-pulse"
/>
))}
</div>
) : cards.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-12 text-center">
<p className="text-muted-foreground">Nenhuma integração disponível no momento.</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card) => (
<IntegrationCard key={card.mcp.id} state={card} agentReady={!!agentReady} />
))}
</div>
)}
</div>
);
}