fix: harden integration oauth flow

This commit is contained in:
Felipe Domingues 2026-05-28 17:54:55 -03:00
parent 9acb125890
commit 354ca092c9
4 changed files with 60 additions and 31 deletions

View file

@ -20,6 +20,7 @@ import { invokeFunction } from "@/lib/invoke-function";
interface Props { interface Props {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onDisconnected?: () => void;
integrationId: string; integrationId: string;
mcpSlug: string; mcpSlug: string;
mcpName: string; mcpName: string;
@ -28,6 +29,7 @@ interface Props {
export function DisconnectMCPDialog({ export function DisconnectMCPDialog({
open, open,
onOpenChange, onOpenChange,
onDisconnected,
integrationId, integrationId,
mcpSlug, mcpSlug,
mcpName, mcpName,
@ -49,6 +51,7 @@ export function DisconnectMCPDialog({
runtime_sync_warning?: string | null; runtime_sync_warning?: string | null;
}>("disconnect-integration", { }>("disconnect-integration", {
integration_id: integrationId, integration_id: integrationId,
force_pause_jobs: dependentJobs.length > 0,
}); });
setSubmitting(false); setSubmitting(false);
if (error) { if (error) {
@ -56,12 +59,16 @@ export function DisconnectMCPDialog({
return; return;
} }
toast.success(`${mcpName} desconectado.`); toast.success(`${mcpName} desconectado.`);
if ((data?.paused_jobs_count ?? 0) > 0) {
toast.info(`${data!.paused_jobs_count} automação(ões) pausada(s).`);
}
if (data?.runtime_sync_warning) { if (data?.runtime_sync_warning) {
toast.warning("Integração removida, mas o runtime do agente não sincronizou."); toast.warning("Integração removida, mas o runtime do agente não sincronizou.");
} }
queryClient.invalidateQueries({ queryKey: ["user-integrations"] }); queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] }); queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] });
onOpenChange(false); onOpenChange(false);
onDisconnected?.();
} }
return ( return (
@ -101,7 +108,7 @@ export function DisconnectMCPDialog({
)} )}
</ul> </ul>
<p className="mt-2 text-xs"> <p className="mt-2 text-xs">
Pause ou exclua essas automações antes de desconectar. Elas serão pausadas automaticamente ao desconectar.
</p> </p>
</div> </div>
</div> </div>
@ -117,7 +124,7 @@ export function DisconnectMCPDialog({
e.preventDefault(); e.preventDefault();
handleDisconnect(); handleDisconnect();
}} }}
disabled={submitting || isLoading || hasActiveJobs} disabled={submitting || isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{submitting ? "Desconectando..." : "Desconectar"} {submitting ? "Desconectando..." : "Desconectar"}

View file

@ -102,14 +102,19 @@ function IntegrationDetailPage() {
async function handleTest() { async function handleTest() {
setTesting(true); setTesting(true);
const { data, error } = await invokeFunction<{ ok: boolean; account?: string }>( const { data, error } = await invokeFunction<{
ok?: boolean;
success?: boolean;
account?: string;
account_info?: unknown;
}>(
"test-integration", "test-integration",
{ integration_id: integration!.id }, { integration_id: integration!.id },
); );
setTesting(false); setTesting(false);
if (error) { if (error) {
toast.error(error.message); toast.error(error.message);
} else if (data?.ok) { } else if (data?.ok || data?.success) {
toast.success("Conexão funcionando perfeitamente."); toast.success("Conexão funcionando perfeitamente.");
} else { } else {
toast.warning("Conexão respondeu, mas com aviso. Verifique status."); toast.warning("Conexão respondeu, mas com aviso. Verifique status.");
@ -289,18 +294,8 @@ function IntegrationDetailPage() {
<DisconnectMCPDialog <DisconnectMCPDialog
open={disconnectOpen} open={disconnectOpen}
onOpenChange={(o) => { onOpenChange={setDisconnectOpen}
setDisconnectOpen(o); onDisconnected={() => navigate({ to: "/painel/integracoes", search: {} })}
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", search: {} });
}
});
}
}}
integrationId={integration.id} integrationId={integration.id}
mcpSlug={mcp.slug} mcpSlug={mcp.slug}
mcpName={mcp.name} mcpName={mcp.name}

View file

@ -23,19 +23,44 @@ export interface ProviderEnv {
redirectUri: string; redirectUri: string;
} }
function readFirstEnv(keys: string[]): string {
for (const key of keys) {
const value = Deno.env.get(key);
if (value) return value;
}
return "";
}
export function getProviderEnv(slug: ProviderSlug, redirectUri: string): ProviderEnv { export function getProviderEnv(slug: ProviderSlug, redirectUri: string): ProviderEnv {
const map: Record<ProviderSlug, [string, string]> = { const map: Record<ProviderSlug, { id: string[]; secret: string[] }> = {
google_workspace: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], google_workspace: {
notion: ["NOTION_CLIENT_ID", "NOTION_CLIENT_SECRET"], id: ["GOOGLE_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_ID"],
todoist: ["TODOIST_CLIENT_ID", "TODOIST_CLIENT_SECRET"], secret: ["GOOGLE_CLIENT_SECRET", "GOOGLE_OAUTH_CLIENT_SECRET"],
calcom: ["CALCOM_CLIENT_ID", "CALCOM_CLIENT_SECRET"], },
microsoft_365: ["MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET"], notion: {
id: ["NOTION_CLIENT_ID", "NOTION_OAUTH_CLIENT_ID"],
secret: ["NOTION_CLIENT_SECRET", "NOTION_OAUTH_CLIENT_SECRET"],
},
todoist: {
id: ["TODOIST_CLIENT_ID", "TODOIST_OAUTH_CLIENT_ID"],
secret: ["TODOIST_CLIENT_SECRET", "TODOIST_OAUTH_CLIENT_SECRET"],
},
calcom: {
id: ["CALCOM_CLIENT_ID", "CALCOM_OAUTH_CLIENT_ID"],
secret: ["CALCOM_CLIENT_SECRET", "CALCOM_OAUTH_CLIENT_SECRET"],
},
microsoft_365: {
id: ["MICROSOFT_CLIENT_ID", "MICROSOFT_OAUTH_CLIENT_ID"],
secret: ["MICROSOFT_CLIENT_SECRET", "MICROSOFT_OAUTH_CLIENT_SECRET"],
},
}; };
const [idKey, secretKey] = map[slug]; const keys = map[slug];
const clientId = Deno.env.get(idKey) ?? ""; const clientId = readFirstEnv(keys.id);
const clientSecret = Deno.env.get(secretKey) ?? ""; const clientSecret = readFirstEnv(keys.secret);
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) {
throw new Error(`Credenciais OAuth ausentes para ${slug} (${idKey}/${secretKey})`); throw new Error(
`Credenciais OAuth ausentes para ${slug} (${keys.id.join(" ou ")}/${keys.secret.join(" ou ")})`,
);
} }
return { clientId, clientSecret, redirectUri }; return { clientId, clientSecret, redirectUri };
} }
@ -415,8 +440,8 @@ export async function revokeToken(
} }
case "todoist": { case "todoist": {
// Todoist precisa client_id/secret + access_token no body // Todoist precisa client_id/secret + access_token no body
const clientId = Deno.env.get("TODOIST_CLIENT_ID") ?? ""; const clientId = readFirstEnv(["TODOIST_CLIENT_ID", "TODOIST_OAUTH_CLIENT_ID"]);
const clientSecret = Deno.env.get("TODOIST_CLIENT_SECRET") ?? ""; const clientSecret = readFirstEnv(["TODOIST_CLIENT_SECRET", "TODOIST_OAUTH_CLIENT_SECRET"]);
const body = new URLSearchParams({ const body = new URLSearchParams({
client_id: clientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,

View file

@ -88,7 +88,7 @@ Deno.serve(async (req) => {
// 1. Checa dependências // 1. Checa dependências
const { data: dependentJobs } = await admin const { data: dependentJobs } = await admin
.from("scheduled_jobs") .from("scheduled_jobs")
.select("id, name") .select("id, name, status")
.eq("user_id", userId) .eq("user_id", userId)
.neq("status", "archived") .neq("status", "archived")
.contains("required_mcp_slugs", [slug]); .contains("required_mcp_slugs", [slug]);
@ -104,7 +104,8 @@ Deno.serve(async (req) => {
} }
let pausedCount = 0; let pausedCount = 0;
if (dependentJobs && dependentJobs.length > 0 && force_pause_jobs) { const jobsToPause = (dependentJobs ?? []).filter((job) => job.status === "active");
if (jobsToPause.length > 0 && force_pause_jobs) {
const { error: pErr } = await admin const { error: pErr } = await admin
.from("scheduled_jobs") .from("scheduled_jobs")
.update({ .update({
@ -112,9 +113,10 @@ Deno.serve(async (req) => {
auto_paused_reason: `Integração ${slug} foi desconectada`, auto_paused_reason: `Integração ${slug} foi desconectada`,
}) })
.eq("user_id", userId) .eq("user_id", userId)
.eq("status", "active")
.neq("status", "archived") .neq("status", "archived")
.contains("required_mcp_slugs", [slug]); .contains("required_mcp_slugs", [slug]);
if (!pErr) pausedCount = dependentJobs.length; if (!pErr) pausedCount = jobsToPause.length;
} }
// 2. Busca tokens ANTES de qualquer delete // 2. Busca tokens ANTES de qualquer delete