From 569f34c63629815c0fc9112d328de68a123cd40c Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:20:20 +0300 Subject: [PATCH 1/4] fix(integrations): isolate Search Console OAuth provider --- .env.example | 6 ++++ packages/ai/src/ai/tools/search-console.ts | 10 ++++-- packages/auth/src/auth.ts | 40 ++++++++++++++++++++++ packages/env/src/app.test.ts | 15 ++++++++ packages/env/src/app.ts | 8 +++++ packages/env/src/public.ts | 4 +++ packages/rpc/src/routers/integrations.ts | 3 +- packages/shared/package.json | 1 + packages/shared/src/integrations.ts | 3 ++ 9 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/integrations.ts diff --git a/.env.example b/.env.example index b9dc1a81ff..9997aa9b16 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ NEXT_PUBLIC_API_URL="" NEXT_PUBLIC_BASKET_URL="" NEXT_PUBLIC_LINKS_URL="" NEXT_PUBLIC_STATUS_URL="" +# Optional production allow/deny lists for integrations. Disabled IDs win over enabled IDs. +NEXT_PUBLIC_ENABLED_INTEGRATIONS="" +NEXT_PUBLIC_DISABLED_INTEGRATIONS="" AI_GATEWAY_API_KEY="" FIRECRAWL_API_KEY="" @@ -46,6 +49,9 @@ GITHUB_CLIENT_SECRET="" GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" +# Use a separate Google Cloud OAuth client for the sensitive Search Console scope. +GOOGLE_SEARCH_CONSOLE_CLIENT_ID="" +GOOGLE_SEARCH_CONSOLE_CLIENT_SECRET="" RESEND_API_KEY="" # Required when RESEND_API_KEY is set; use a domain verified in Resend. diff --git a/packages/ai/src/ai/tools/search-console.ts b/packages/ai/src/ai/tools/search-console.ts index 48f89a3010..724fcc28ad 100644 --- a/packages/ai/src/ai/tools/search-console.ts +++ b/packages/ai/src/ai/tools/search-console.ts @@ -3,6 +3,10 @@ import { z } from "zod"; import { getWebsiteDomain } from "../../lib/website-utils"; import { getAppContext, resolveToolWebsite, toolDateRangeError } from "./utils"; import { createCachedTokenFn } from "./utils/oauth-token"; +import { + GOOGLE_SEARCH_CONSOLE_PROVIDER_ID, + GOOGLE_SEARCH_CONSOLE_SCOPE, +} from "@databuddy/shared/integrations"; const GSC_API = "https://www.googleapis.com/webmasters/v3"; const MAX_ROWS = 25; @@ -99,10 +103,10 @@ export function createSearchConsoleTools(params: { userId?: string; }) { const getToken = createCachedTokenFn( - "google", + GOOGLE_SEARCH_CONSOLE_PROVIDER_ID, params.organizationId, params.userId, - "https://www.googleapis.com/auth/webmasters.readonly" + GOOGLE_SEARCH_CONSOLE_SCOPE ); return { @@ -139,7 +143,7 @@ export function createSearchConsoleTools(params: { if (!token) { return { error: - "No Google account connected. Connect Google in Settings > Integrations with Search Console scope.", + "Google Search Console is not connected. Connect it in Settings > Integrations.", }; } try { diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 58887fd987..60f9508928 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -46,6 +46,7 @@ import { APIError } from "better-auth/api"; import { betterAuth } from "better-auth/minimal"; import { emailOTP, + genericOAuth, lastLoginMethod, magicLink, multiSession, @@ -56,6 +57,10 @@ import { log } from "evlog"; import { Resend } from "resend"; import { ac, admin, member, owner, viewer } from "./permissions"; import { getAuthAuditContext } from "./audit-context"; +import { + GOOGLE_SEARCH_CONSOLE_PROVIDER_ID, + GOOGLE_SEARCH_CONSOLE_SCOPE, +} from "@databuddy/shared/integrations"; function generateOrgSlug(name: string): string { const base = name @@ -219,6 +224,40 @@ function formatInvitationRole(role: string | string[]): string { const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL ?? ""; +const googleSearchConsoleClientId = + process.env.GOOGLE_SEARCH_CONSOLE_CLIENT_ID?.trim(); +const googleSearchConsoleClientSecret = + process.env.GOOGLE_SEARCH_CONSOLE_CLIENT_SECRET?.trim(); +const googleSearchConsoleOAuth = + (process.env.NODE_ENV !== "production" || + (config.integrations.enabledIds.includes( + GOOGLE_SEARCH_CONSOLE_PROVIDER_ID + ) && + !config.integrations.disabledIds.includes( + GOOGLE_SEARCH_CONSOLE_PROVIDER_ID + ))) && + googleSearchConsoleClientId && + googleSearchConsoleClientSecret + ? genericOAuth({ + config: [ + { + accessType: "offline", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + clientId: googleSearchConsoleClientId, + clientSecret: googleSearchConsoleClientSecret, + disableImplicitSignUp: true, + disableSignUp: true, + pkce: true, + prompt: "consent", + providerId: GOOGLE_SEARCH_CONSOLE_PROVIDER_ID, + scopes: ["openid", "email", "profile", GOOGLE_SEARCH_CONSOLE_SCOPE], + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo", + }, + ], + }) + : null; + function notifySlack( title: string, message: string, @@ -613,6 +652,7 @@ export const auth = betterAuth({ }, }, plugins: [ + ...(googleSearchConsoleOAuth ? [googleSearchConsoleOAuth] : []), multiSession({ maximumSessions: 5, }), diff --git a/packages/env/src/app.test.ts b/packages/env/src/app.test.ts index 819eb7de5b..0538000a7a 100644 --- a/packages/env/src/app.test.ts +++ b/packages/env/src/app.test.ts @@ -106,6 +106,21 @@ describe("createConfig", () => { }); }); + it("parses public integration readiness overrides", () => { + expect( + createConfig({ + NEXT_PUBLIC_DISABLED_INTEGRATIONS: + " google-search-console, slack, google-search-console ", + NEXT_PUBLIC_ENABLED_INTEGRATIONS: " github, mcp ", + }) + ).toMatchObject({ + integrations: { + disabledIds: ["google-search-console", "slack", "google-search-console"], + enabledIds: ["github", "mcp"], + }, + }); + }); + it("deduplicates API CORS origins from dashboard URLs", () => { expect( createConfig({ diff --git a/packages/env/src/app.ts b/packages/env/src/app.ts index da715ca3cc..dabf6df349 100644 --- a/packages/env/src/app.ts +++ b/packages/env/src/app.ts @@ -65,6 +65,8 @@ export interface Config { resendApiKey?: string; }; integrations: { + disabledIds: string[]; + enabledIds: string[]; openAiAdsPixelId?: string; }; urls: { @@ -146,6 +148,12 @@ export function createConfig(env: Env = process.env): Config { resendApiKey: readOptional(env, "RESEND_API_KEY"), }, integrations: { + disabledIds: readList( + readOptional(env, "NEXT_PUBLIC_DISABLED_INTEGRATIONS") + ), + enabledIds: readList( + readOptional(env, "NEXT_PUBLIC_ENABLED_INTEGRATIONS") + ), openAiAdsPixelId: readOptional(env, "NEXT_PUBLIC_OPENAI_ADS_PIXEL_ID"), }, urls: { diff --git a/packages/env/src/public.ts b/packages/env/src/public.ts index 26b79baf91..5bcd702f11 100644 --- a/packages/env/src/public.ts +++ b/packages/env/src/public.ts @@ -4,6 +4,10 @@ export const publicConfig = createConfig({ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_BASKET_URL: process.env.NEXT_PUBLIC_BASKET_URL, + NEXT_PUBLIC_DISABLED_INTEGRATIONS: + process.env.NEXT_PUBLIC_DISABLED_INTEGRATIONS, + NEXT_PUBLIC_ENABLED_INTEGRATIONS: + process.env.NEXT_PUBLIC_ENABLED_INTEGRATIONS, NEXT_PUBLIC_LINKS_URL: process.env.NEXT_PUBLIC_LINKS_URL, NEXT_PUBLIC_OPENAI_ADS_PIXEL_ID: process.env.NEXT_PUBLIC_OPENAI_ADS_PIXEL_ID, NEXT_PUBLIC_STATUS_URL: process.env.NEXT_PUBLIC_STATUS_URL, diff --git a/packages/rpc/src/routers/integrations.ts b/packages/rpc/src/routers/integrations.ts index 19daf1f43f..b2f383a0c1 100644 --- a/packages/rpc/src/routers/integrations.ts +++ b/packages/rpc/src/routers/integrations.ts @@ -7,6 +7,7 @@ import { } from "@databuddy/db/schema"; import type { WebsiteIntegrations } from "@databuddy/db/schema"; import { invalidateSlackIntegrationCache } from "@databuddy/redis"; +import { GOOGLE_SEARCH_CONSOLE_PROVIDER_ID } from "@databuddy/shared/integrations"; import { z } from "zod"; import { rpcError } from "../errors"; import type { Context } from "../orpc"; @@ -394,7 +395,7 @@ export const integrationsRouter = { const token = await getUserProviderToken( context.db, context.user.id, - "google" + GOOGLE_SEARCH_CONSOLE_PROVIDER_ID ); if (!token) { return { hasAccess: false }; diff --git a/packages/shared/package.json b/packages/shared/package.json index 4be6115179..838f20d7a1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -4,6 +4,7 @@ "version": "0.0.1", "type": "module", "exports": { + "./integrations": "./src/integrations.ts", "./api-scopes": "./src/api-scopes.ts", "./agent-discovery": "./src/agent-discovery.ts", "./country-codes": "./src/country-codes.ts", diff --git a/packages/shared/src/integrations.ts b/packages/shared/src/integrations.ts new file mode 100644 index 0000000000..b8646d742b --- /dev/null +++ b/packages/shared/src/integrations.ts @@ -0,0 +1,3 @@ +export const GOOGLE_SEARCH_CONSOLE_PROVIDER_ID = "google-search-console"; +export const GOOGLE_SEARCH_CONSOLE_SCOPE = + "https://www.googleapis.com/auth/webmasters.readonly"; From ba33eae82b99ff6ff98f38ad834d2e3a6d483d33 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:20:38 +0300 Subject: [PATCH 2/4] fix(dashboard): make integrations usable on mobile --- .../components/integrations-settings.tsx | 292 +++++++++++++----- .../organizations/mcp-setup-sheet.tsx | 38 ++- 2 files changed, 239 insertions(+), 91 deletions(-) diff --git a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx index 0fff4114c8..56bd9457ef 100644 --- a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx @@ -3,6 +3,7 @@ import { authClient } from "@databuddy/auth/client"; import { publicConfig } from "@databuddy/env/public"; import type { SlackIntegrationOutput } from "@databuddy/rpc"; +import { GOOGLE_SEARCH_CONSOLE_PROVIDER_ID } from "@databuddy/shared/integrations"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; @@ -46,6 +47,7 @@ interface IntegrationCatalogItem { iconPath: string; id: string; name: string; + productionReady?: boolean; } const SIMPLE_ICONS = { @@ -118,10 +120,9 @@ const GSC_ITEM: IntegrationCatalogItem = { iconPath: SIMPLE_ICONS.googlesearchconsole, id: "google-search-console", name: "Google Search Console", + productionReady: false, }; -const GSC_SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]; - const COMING_SOON_INTEGRATIONS: IntegrationCatalogItem[] = [ { accent: "#5E6AD2", @@ -217,8 +218,9 @@ function slackInstallUrl(organizationId: string): string { return url.toString(); } -function useLinkedAccounts() { +function useLinkedAccounts(enabled = true) { return useQuery({ + enabled, queryKey: ["linked-accounts"], queryFn: async () => { const result = await authClient.listAccounts(); @@ -227,14 +229,23 @@ function useLinkedAccounts() { }); } -function useOAuthConnect(provider: string, scopes: string[], label: string) { +function useOAuthConnect( + provider: string, + scopes: string[] | undefined, + label: string +) { return useMutation({ mutationFn: async () => { - const result = await authClient.linkSocial({ - provider, - scopes, - callbackURL: window.location.href, - }); + const result = scopes + ? await authClient.linkSocial({ + provider, + scopes, + callbackURL: window.location.href, + }) + : await authClient.oauth2.link({ + callbackURL: window.location.href, + providerId: provider, + }); if (result.error) { throw new Error(result.error.message); } @@ -246,6 +257,36 @@ function useOAuthConnect(provider: string, scopes: string[], label: string) { }); } +function isIntegrationEnabled(item: IntegrationCatalogItem): boolean { + if (process.env.NODE_ENV !== "production") { + return true; + } + if (publicConfig.integrations.disabledIds.includes(item.id)) { + return false; + } + if (publicConfig.integrations.enabledIds.includes(item.id)) { + return true; + } + return item.productionReady !== false; +} + +function IntegrationUnavailableBadge() { + return ( + + Not ready + + ); +} + +function IntegrationUnavailableButton() { + return ( + + ); +} + function ConnectionBadge({ connected, loading, @@ -276,7 +317,7 @@ function ConnectionBadge({ function LoadingButton() { return ( - @@ -320,6 +361,9 @@ export function IntegrationsSettings({ const searchParams = useSearchParams(); const [pendingUninstall, setPendingUninstall] = useState(null); + const mcpEnabled = isIntegrationEnabled(MCP_ITEM); + const slackEnabled = isIntegrationEnabled(SLACK_ITEM); + const githubEnabled = isIntegrationEnabled(GITHUB_ITEM); const listKey = orpc.integrations.list.key({ input: { organizationId: organization.id }, }); @@ -338,6 +382,7 @@ export function IntegrationsSettings({ ...orpc.integrations.list.queryOptions({ input: { organizationId: organization.id }, }), + enabled: slackEnabled, }); const uninstallSlack = useMutation({ @@ -364,7 +409,7 @@ export function IntegrationsSettings({ />
-
+
Integrations @@ -374,11 +419,15 @@ export function IntegrationsSettings({ - + - + {COMING_SOON_INTEGRATIONS.map((item) => ( + @@ -435,29 +492,46 @@ export function IntegrationsSettings({ } function GSCIntegrationRow() { - const accounts = useLinkedAccounts(); - const googleAccount = accounts.data?.find((a) => a.providerId === "google"); + const enabled = isIntegrationEnabled(GSC_ITEM); + const item = enabled + ? GSC_ITEM + : { + ...GSC_ITEM, + description: + "Temporarily unavailable in production while Google verification is pending.", + }; + const accounts = useLinkedAccounts(enabled); + const googleSearchConsoleAccount = accounts.data?.find( + (a) => a.providerId === GOOGLE_SEARCH_CONSOLE_PROVIDER_ID + ); const gscCheck = useQuery({ ...orpc.integrations.checkSearchConsoleAccess.queryOptions({ input: {}, }), - enabled: Boolean(googleAccount), + enabled: enabled && Boolean(googleSearchConsoleAccount), }); const hasGscAccess = gscCheck.data?.hasAccess === true; const connect = useOAuthConnect( - "google", - GSC_SCOPES, + GOOGLE_SEARCH_CONSOLE_PROVIDER_ID, + undefined, "Google Search Console" ); let action: React.ReactNode; - if (accounts.isLoading || gscCheck.isLoading) { + if (!enabled) { + action = ; + } else if (accounts.isLoading || gscCheck.isLoading) { action = ; } else if (hasGscAccess) { action = ( - @@ -465,6 +539,7 @@ function GSCIntegrationRow() { } else { action = ( ); } @@ -481,19 +556,29 @@ function GSCIntegrationRow() { + enabled ? ( + + ) : ( + + ) } - item={GSC_ITEM} + item={item} /> ); } -function GitHubIntegrationRow({ organizationId }: { organizationId: string }) { +function GitHubIntegrationRow({ + enabled, + organizationId, +}: { + enabled: boolean; + organizationId: string; +}) { const queryClient = useQueryClient(); - const accounts = useLinkedAccounts(); + const accounts = useLinkedAccounts(enabled); const githubAccount = accounts.data?.find((a) => a.providerId === "github"); const canDisconnect = (accounts.data?.length ?? 0) > 1; @@ -501,7 +586,7 @@ function GitHubIntegrationRow({ organizationId }: { organizationId: string }) { ...orpc.websites.list.queryOptions({ input: { organizationId }, }), - enabled: Boolean(githubAccount), + enabled: enabled && Boolean(githubAccount), }); const connect = useOAuthConnect("github", GITHUB_SCOPES, "GitHub"); @@ -549,13 +634,16 @@ function GitHubIntegrationRow({ organizationId }: { organizationId: string }) { }); let action: React.ReactNode; - if (accounts.isLoading) { + if (!enabled) { + action = ; + } else if (accounts.isLoading) { action = ; } else if (githubAccount) { action = ( -
+
{canDisconnect && ( )} + enabled ? ( + + ) : ( + + ) } badge={statusBadge} defaultOpen={activeMcpKeys.length > 0} item={MCP_ITEM} > - + {enabled ? ( + + ) : undefined} void; @@ -971,11 +1085,18 @@ function SlackIntegrationRow({ const connected = integrations.some((i) => i.status === "active"); let action: React.ReactNode; - if (isLoading) { + if (!enabled) { + action = ; + } else if (isLoading) { action = ; } else if (connected) { action = ( - @@ -983,7 +1104,10 @@ function SlackIntegrationRow({ } else { action = ( @@ -995,16 +1119,24 @@ function SlackIntegrationRow({ return ( } + badge={ + enabled ? ( + + ) : ( + + ) + } defaultOpen={false} item={SLACK_ITEM} > - + {enabled ? ( + + ) : undefined} ); } @@ -1045,12 +1177,14 @@ function IntegrationListRow({ if (!children) { return (
-
-
{header}
-
+
+
+ {header} +
+
{action}
@@ -1064,11 +1198,11 @@ function IntegrationListRow({ id={item.id} > -
+
{header} -
+
{action}
diff --git a/apps/dashboard/components/organizations/mcp-setup-sheet.tsx b/apps/dashboard/components/organizations/mcp-setup-sheet.tsx index 433bb22aa9..3478fd3b4b 100644 --- a/apps/dashboard/components/organizations/mcp-setup-sheet.tsx +++ b/apps/dashboard/components/organizations/mcp-setup-sheet.tsx @@ -320,8 +320,11 @@ export function McpSetupSheet({ return ( - - + +
@@ -339,7 +342,7 @@ export function McpSetupSheet({
- + {newSecret ? ( AI client ({ @@ -529,17 +532,26 @@ export function McpSetupSheet({ )} - + {newSecret ? ( - ) : ( <> -