Criou frontend de Integrações

X-Lovable-Edit-ID: edt-10d51684-ec05-41b3-9813-b9273e5b89e3
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-18 02:07:07 +00:00
commit b588e49fa1
7 changed files with 861 additions and 2 deletions

View file

@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Desconectar {mcpName}?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
A conexão será removida e os tokens serão revogados. Você pode reconectar
a qualquer momento.
</p>
{isLoading && (
<p className="text-sm text-muted-foreground">
Verificando automações dependentes...
</p>
)}
{!isLoading && hasActiveJobs && (
<div className="rounded-md border border-destructive bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium text-destructive">
{activeJobs.length}{" "}
{activeJobs.length === 1 ? "automação ativa" : "automações ativas"}{" "}
depende{activeJobs.length === 1 ? "" : "m"} desta integração:
</p>
<ul className="list-disc list-inside mt-1">
{activeJobs.slice(0, 5).map((j) => (
<li key={j.id}>{j.name}</li>
))}
{activeJobs.length > 5 && (
<li>e mais {activeJobs.length - 5}...</li>
)}
</ul>
<p className="mt-2 text-xs">
Pause ou exclua essas automações antes de desconectar.
</p>
</div>
</div>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDisconnect();
}}
disabled={submitting || isLoading || hasActiveJobs}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{submitting ? "Desconectando..." : "Desconectar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -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 (
<div className="rounded-xl border border-border bg-card p-5 shadow-soft flex flex-col gap-4">
<div className="flex items-start gap-3">
<img
src={mcp.icon_url}
alt=""
className="h-10 w-10 rounded-md object-contain bg-muted/40 p-1"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold truncate">{mcp.name}</h3>
{state.kind === "connected" && (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> Conectado
</Badge>
)}
{state.kind === "error" && (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
{state.integration.status === "expired"
? "Expirado"
: state.integration.status === "revoked"
? "Revogado"
: "Erro"}
</Badge>
)}
{state.kind === "locked" && (
<Badge variant="secondary" className="gap-1">
<Lock className="h-3 w-3" /> Bloqueado
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{mcp.description}
</p>
</div>
</div>
{state.kind === "connected" && state.integration.connected_account_email && (
<p className="text-xs text-muted-foreground truncate">
Conta: <span className="font-mono">{state.integration.connected_account_email}</span>
</p>
)}
{state.kind === "error" && state.integration.error_message && (
<p className="text-xs text-destructive line-clamp-2">
{state.integration.error_message}
</p>
)}
{state.kind === "locked" && (
<p className="text-xs text-muted-foreground">
Disponível nos planos: {state.mcp.available_in_plans.join(", ") || "—"}
</p>
)}
<div className="flex gap-2 mt-auto">
{state.kind === "available" && (
<Button onClick={handleConnect} disabled={connecting || !agentReady} className="flex-1">
<Plug className="h-4 w-4 mr-2" />
{connecting ? "Conectando..." : "Conectar"}
</Button>
)}
{state.kind === "locked" && (
<Button asChild variant="outline" className="flex-1">
<Link to="/painel/faturamento">Fazer upgrade</Link>
</Button>
)}
{(state.kind === "connected" || state.kind === "error") && (
<Button asChild variant="outline" className="flex-1">
<Link to="/painel/integracoes/$slug" params={{ slug: mcp.slug }}>
Gerenciar
</Link>
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,174 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { useSubscription } from "@/hooks/use-profile";
export interface AvailableMcp {
id: string;
slug: string;
name: string;
provider: string;
description: string;
icon_url: string;
oauth_authorize_url: string;
oauth_token_url: string;
oauth_revoke_url: string | null;
required_scopes: string[];
available_in_plans: string[];
supports_refresh_token: boolean;
display_order: number;
is_active: boolean;
}
export interface UserIntegration {
id: string;
user_id: string;
mcp_id: string;
status: "active" | "expired" | "revoked" | "error";
connected_account_email: string | null;
connected_account_name: string | null;
granted_scopes: string[];
token_expires_at: string | null;
last_refreshed_at: string | null;
error_message: string | null;
created_at: string;
updated_at: string;
}
export type IntegrationCardState =
| { kind: "available"; mcp: AvailableMcp }
| { kind: "locked"; mcp: AvailableMcp; userPlan: string | null }
| { kind: "connected"; mcp: AvailableMcp; integration: UserIntegration }
| { kind: "error"; mcp: AvailableMcp; integration: UserIntegration };
export function useAvailableMcps() {
return useQuery({
queryKey: ["available-mcps"],
queryFn: async (): Promise<AvailableMcp[]> => {
const { data, error } = await supabase
.from("available_mcps")
.select("*")
.eq("is_active", true)
.order("display_order", { ascending: true });
if (error) throw error;
return (data ?? []).map((row) => ({
...row,
required_scopes: Array.isArray(row.required_scopes)
? (row.required_scopes as string[])
: [],
available_in_plans: Array.isArray(row.available_in_plans)
? (row.available_in_plans as string[])
: [],
})) as AvailableMcp[];
},
});
}
export function useUserIntegrations() {
const { user } = useAuth();
return useQuery({
queryKey: ["user-integrations", user?.id],
enabled: !!user,
queryFn: async (): Promise<UserIntegration[]> => {
const { data, error } = await supabase
.from("user_integrations")
.select(
"id, user_id, mcp_id, status, connected_account_email, connected_account_name, granted_scopes, token_expires_at, last_refreshed_at, error_message, created_at, updated_at",
)
.eq("user_id", user!.id);
if (error) throw error;
return (data ?? []).map((row) => ({
...row,
granted_scopes: Array.isArray(row.granted_scopes)
? (row.granted_scopes as string[])
: [],
})) as UserIntegration[];
},
});
}
export function useUserIntegrationLimits() {
const { user } = useAuth();
return useQuery({
queryKey: ["user-integration-limits", user?.id],
enabled: !!user,
queryFn: async () => {
const { data, error } = await supabase
.from("user_integration_limits")
.select("*")
.eq("user_id", user!.id)
.maybeSingle();
if (error) throw error;
return data as {
user_id: string;
plan_slug: string | null;
current_integrations_count: number | null;
max_integrations: number | null;
} | null;
},
});
}
/**
* Combina available_mcps + user_integrations + plano em estados de cartão.
*/
export function useIntegrationCards() {
const mcpsQ = useAvailableMcps();
const integsQ = useUserIntegrations();
const subQ = useSubscription();
const userPlan = subQ.data?.plan_id ?? null;
// Para checagem de plano usamos a view de limites (que tem plan_slug derivado)
const limitsQ = useUserIntegrationLimits();
const planSlug = limitsQ.data?.plan_slug ?? null;
const isLoading = mcpsQ.isLoading || integsQ.isLoading || limitsQ.isLoading;
const error = mcpsQ.error ?? integsQ.error ?? limitsQ.error;
const cards: IntegrationCardState[] = (mcpsQ.data ?? []).map((mcp) => {
const integ = (integsQ.data ?? []).find((i) => i.mcp_id === mcp.id);
if (integ) {
if (integ.status === "active") {
return { kind: "connected", mcp, integration: integ };
}
return { kind: "error", mcp, integration: integ };
}
const planAllows = !planSlug || mcp.available_in_plans.length === 0
? true
: mcp.available_in_plans.includes(planSlug);
if (!planAllows) {
return { kind: "locked", mcp, userPlan: planSlug };
}
return { kind: "available", mcp };
});
return {
cards,
isLoading,
error,
planSlug,
userPlan,
limit: limitsQ.data,
};
}
export function useDependentJobsForMcp(mcpSlug: string | undefined) {
const { user } = useAuth();
return useQuery({
queryKey: ["dependent-jobs", user?.id, mcpSlug],
enabled: !!user && !!mcpSlug,
queryFn: async (): Promise<{ id: string; name: string; status: string }[]> => {
const { data, error } = await supabase
.from("scheduled_jobs")
.select("id, name, status, required_mcp_slugs")
.eq("user_id", user!.id);
if (error) throw error;
return (data ?? [])
.filter((j) => Array.isArray(j.required_mcp_slugs) && (j.required_mcp_slugs as string[]).includes(mcpSlug!))
.map((j) => ({ id: j.id, name: j.name, status: j.status }));
},
});
}

View file

@ -22,9 +22,11 @@ import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configu
import { Route as PainelAgenteRouteImport } from './routes/painel.agente' import { Route as PainelAgenteRouteImport } from './routes/painel.agente'
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso' import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
import { Route as PainelSkillsIndexRouteImport } from './routes/painel.skills.index' import { Route as PainelSkillsIndexRouteImport } from './routes/painel.skills.index'
import { Route as PainelIntegracoesIndexRouteImport } from './routes/painel.integracoes.index'
import { Route as PainelSkillsPreviewRouteImport } from './routes/painel.skills.preview' import { Route as PainelSkillsPreviewRouteImport } from './routes/painel.skills.preview'
import { Route as PainelSkillsNovaRouteImport } from './routes/painel.skills.nova' import { Route as PainelSkillsNovaRouteImport } from './routes/painel.skills.nova'
import { Route as PainelSkillsIdRouteImport } from './routes/painel.skills.$id' import { Route as PainelSkillsIdRouteImport } from './routes/painel.skills.$id'
import { Route as PainelIntegracoesSlugRouteImport } from './routes/painel.integracoes.$slug'
const SignupRoute = SignupRouteImport.update({ const SignupRoute = SignupRouteImport.update({
id: '/signup', id: '/signup',
@ -91,6 +93,11 @@ const PainelSkillsIndexRoute = PainelSkillsIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => PainelSkillsRoute, getParentRoute: () => PainelSkillsRoute,
} as any) } as any)
const PainelIntegracoesIndexRoute = PainelIntegracoesIndexRouteImport.update({
id: '/integracoes/',
path: '/integracoes/',
getParentRoute: () => PainelRoute,
} as any)
const PainelSkillsPreviewRoute = PainelSkillsPreviewRouteImport.update({ const PainelSkillsPreviewRoute = PainelSkillsPreviewRouteImport.update({
id: '/preview', id: '/preview',
path: '/preview', path: '/preview',
@ -106,6 +113,11 @@ const PainelSkillsIdRoute = PainelSkillsIdRouteImport.update({
path: '/$id', path: '/$id',
getParentRoute: () => PainelSkillsRoute, getParentRoute: () => PainelSkillsRoute,
} as any) } as any)
const PainelIntegracoesSlugRoute = PainelIntegracoesSlugRouteImport.update({
id: '/integracoes/$slug',
path: '/integracoes/$slug',
getParentRoute: () => PainelRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -120,9 +132,11 @@ export interface FileRoutesByFullPath {
'/painel/faturamento': typeof PainelFaturamentoRoute '/painel/faturamento': typeof PainelFaturamentoRoute
'/painel/skills': typeof PainelSkillsRouteWithChildren '/painel/skills': typeof PainelSkillsRouteWithChildren
'/painel/': typeof PainelIndexRoute '/painel/': typeof PainelIndexRoute
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
'/painel/skills/$id': typeof PainelSkillsIdRoute '/painel/skills/$id': typeof PainelSkillsIdRoute
'/painel/skills/nova': typeof PainelSkillsNovaRoute '/painel/skills/nova': typeof PainelSkillsNovaRoute
'/painel/skills/preview': typeof PainelSkillsPreviewRoute '/painel/skills/preview': typeof PainelSkillsPreviewRoute
'/painel/integracoes/': typeof PainelIntegracoesIndexRoute
'/painel/skills/': typeof PainelSkillsIndexRoute '/painel/skills/': typeof PainelSkillsIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@ -136,9 +150,11 @@ export interface FileRoutesByTo {
'/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute '/painel/faturamento': typeof PainelFaturamentoRoute
'/painel': typeof PainelIndexRoute '/painel': typeof PainelIndexRoute
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
'/painel/skills/$id': typeof PainelSkillsIdRoute '/painel/skills/$id': typeof PainelSkillsIdRoute
'/painel/skills/nova': typeof PainelSkillsNovaRoute '/painel/skills/nova': typeof PainelSkillsNovaRoute
'/painel/skills/preview': typeof PainelSkillsPreviewRoute '/painel/skills/preview': typeof PainelSkillsPreviewRoute
'/painel/integracoes': typeof PainelIntegracoesIndexRoute
'/painel/skills': typeof PainelSkillsIndexRoute '/painel/skills': typeof PainelSkillsIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@ -155,9 +171,11 @@ export interface FileRoutesById {
'/painel/faturamento': typeof PainelFaturamentoRoute '/painel/faturamento': typeof PainelFaturamentoRoute
'/painel/skills': typeof PainelSkillsRouteWithChildren '/painel/skills': typeof PainelSkillsRouteWithChildren
'/painel/': typeof PainelIndexRoute '/painel/': typeof PainelIndexRoute
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
'/painel/skills/$id': typeof PainelSkillsIdRoute '/painel/skills/$id': typeof PainelSkillsIdRoute
'/painel/skills/nova': typeof PainelSkillsNovaRoute '/painel/skills/nova': typeof PainelSkillsNovaRoute
'/painel/skills/preview': typeof PainelSkillsPreviewRoute '/painel/skills/preview': typeof PainelSkillsPreviewRoute
'/painel/integracoes/': typeof PainelIntegracoesIndexRoute
'/painel/skills/': typeof PainelSkillsIndexRoute '/painel/skills/': typeof PainelSkillsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@ -175,9 +193,11 @@ export interface FileRouteTypes {
| '/painel/faturamento' | '/painel/faturamento'
| '/painel/skills' | '/painel/skills'
| '/painel/' | '/painel/'
| '/painel/integracoes/$slug'
| '/painel/skills/$id' | '/painel/skills/$id'
| '/painel/skills/nova' | '/painel/skills/nova'
| '/painel/skills/preview' | '/painel/skills/preview'
| '/painel/integracoes/'
| '/painel/skills/' | '/painel/skills/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
@ -191,9 +211,11 @@ export interface FileRouteTypes {
| '/painel/configuracoes' | '/painel/configuracoes'
| '/painel/faturamento' | '/painel/faturamento'
| '/painel' | '/painel'
| '/painel/integracoes/$slug'
| '/painel/skills/$id' | '/painel/skills/$id'
| '/painel/skills/nova' | '/painel/skills/nova'
| '/painel/skills/preview' | '/painel/skills/preview'
| '/painel/integracoes'
| '/painel/skills' | '/painel/skills'
id: id:
| '__root__' | '__root__'
@ -209,9 +231,11 @@ export interface FileRouteTypes {
| '/painel/faturamento' | '/painel/faturamento'
| '/painel/skills' | '/painel/skills'
| '/painel/' | '/painel/'
| '/painel/integracoes/$slug'
| '/painel/skills/$id' | '/painel/skills/$id'
| '/painel/skills/nova' | '/painel/skills/nova'
| '/painel/skills/preview' | '/painel/skills/preview'
| '/painel/integracoes/'
| '/painel/skills/' | '/painel/skills/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@ -318,6 +342,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PainelSkillsIndexRouteImport preLoaderRoute: typeof PainelSkillsIndexRouteImport
parentRoute: typeof PainelSkillsRoute parentRoute: typeof PainelSkillsRoute
} }
'/painel/integracoes/': {
id: '/painel/integracoes/'
path: '/integracoes'
fullPath: '/painel/integracoes/'
preLoaderRoute: typeof PainelIntegracoesIndexRouteImport
parentRoute: typeof PainelRoute
}
'/painel/skills/preview': { '/painel/skills/preview': {
id: '/painel/skills/preview' id: '/painel/skills/preview'
path: '/preview' path: '/preview'
@ -339,6 +370,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PainelSkillsIdRouteImport preLoaderRoute: typeof PainelSkillsIdRouteImport
parentRoute: typeof PainelSkillsRoute parentRoute: typeof PainelSkillsRoute
} }
'/painel/integracoes/$slug': {
id: '/painel/integracoes/$slug'
path: '/integracoes/$slug'
fullPath: '/painel/integracoes/$slug'
preLoaderRoute: typeof PainelIntegracoesSlugRouteImport
parentRoute: typeof PainelRoute
}
} }
} }
@ -366,6 +404,8 @@ interface PainelRouteChildren {
PainelFaturamentoRoute: typeof PainelFaturamentoRoute PainelFaturamentoRoute: typeof PainelFaturamentoRoute
PainelSkillsRoute: typeof PainelSkillsRouteWithChildren PainelSkillsRoute: typeof PainelSkillsRouteWithChildren
PainelIndexRoute: typeof PainelIndexRoute PainelIndexRoute: typeof PainelIndexRoute
PainelIntegracoesSlugRoute: typeof PainelIntegracoesSlugRoute
PainelIntegracoesIndexRoute: typeof PainelIntegracoesIndexRoute
} }
const PainelRouteChildren: PainelRouteChildren = { const PainelRouteChildren: PainelRouteChildren = {
@ -374,6 +414,8 @@ const PainelRouteChildren: PainelRouteChildren = {
PainelFaturamentoRoute: PainelFaturamentoRoute, PainelFaturamentoRoute: PainelFaturamentoRoute,
PainelSkillsRoute: PainelSkillsRouteWithChildren, PainelSkillsRoute: PainelSkillsRouteWithChildren,
PainelIndexRoute: PainelIndexRoute, PainelIndexRoute: PainelIndexRoute,
PainelIntegracoesSlugRoute: PainelIntegracoesSlugRoute,
PainelIntegracoesIndexRoute: PainelIntegracoesIndexRoute,
} }
const PainelRouteWithChildren = const PainelRouteWithChildren =

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,91 @@
"use client";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Plug } from "lucide-react";
import { useEffect } from "react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
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/")({
validateSearch: (search: Record<string, unknown>) => ({
status: typeof search.status === "string" ? search.status : undefined,
error: typeof search.error === "string" ? search.error : undefined,
mcp: typeof search.mcp === "string" ? search.mcp : undefined,
}),
component: IntegracoesPage,
});
function IntegracoesPage() {
const { cards, isLoading, error, limit } = useIntegrationCards();
const { data: agent } = useAgentInstance();
const agentReady = agent?.status === "active" || agent?.status === "ready";
const search = Route.useSearch();
const navigate = useNavigate();
const queryClient = useQueryClient();
useEffect(() => {
if (search.status === "success" && search.mcp) {
toast.success(`${search.mcp} conectado com sucesso!`);
queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] });
navigate({ to: "/painel/integracoes", search: {}, replace: true });
} else if (search.error) {
toast.error(`Erro ao conectar: ${search.error}`);
navigate({ to: "/painel/integracoes", search: {}, replace: true });
}
}, [search.status, search.error, search.mcp, navigate, queryClient]);
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>
);
}

View file

@ -59,7 +59,7 @@ export const Route = createFileRoute("/painel")({
}); });
interface NavItem { interface NavItem {
to: "/painel" | "/painel/agente" | "/painel/skills" | "/painel/faturamento" | "/painel/configuracoes"; to: "/painel" | "/painel/agente" | "/painel/skills" | "/painel/integracoes" | "/painel/faturamento" | "/painel/configuracoes";
label: string; label: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
disabled?: boolean; disabled?: boolean;
@ -76,7 +76,7 @@ const NAV: (NavItem | DisabledNavItem)[] = [
{ to: "/painel", label: "Dashboard", icon: Home }, { to: "/painel", label: "Dashboard", icon: Home },
{ to: "/painel/agente", label: "Meu Agente", icon: Bot }, { to: "/painel/agente", label: "Meu Agente", icon: Bot },
{ to: "/painel/skills", label: "Skills", icon: Sparkles }, { to: "/painel/skills", label: "Skills", icon: Sparkles },
{ to: null, label: "Integrações", icon: Plug, disabled: true }, { to: "/painel/integracoes", label: "Integrações", icon: Plug },
{ to: "/painel/faturamento", label: "Faturamento", icon: CreditCard }, { to: "/painel/faturamento", label: "Faturamento", icon: CreditCard },
{ to: "/painel/configuracoes", label: "Configurações", icon: Settings }, { to: "/painel/configuracoes", label: "Configurações", icon: Settings },
]; ];