diff --git a/.env.example b/.env.example index b9dc1a81ff..8c14d9d658 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,6 @@ NEXT_PUBLIC_API_URL="" NEXT_PUBLIC_BASKET_URL="" NEXT_PUBLIC_LINKS_URL="" NEXT_PUBLIC_STATUS_URL="" - AI_GATEWAY_API_KEY="" FIRECRAWL_API_KEY="" SUPERMEMORY_API_KEY="" @@ -46,6 +45,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/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx index 0fff4114c8..b95212b96c 100644 --- a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx @@ -3,6 +3,10 @@ import { authClient } from "@databuddy/auth/client"; import { publicConfig } from "@databuddy/env/public"; import type { SlackIntegrationOutput } from "@databuddy/rpc"; +import { + GOOGLE_SEARCH_CONSOLE_PROVIDER_ID, + INTEGRATION_PRODUCTION_READY, +} from "@databuddy/shared/integrations"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; @@ -36,8 +40,6 @@ import { } from "@databuddy/ui"; import { Accordion, Autocomplete, DeleteDialog } from "@databuddy/ui/client"; -type SlackIntegration = SlackIntegrationOutput; - interface IntegrationCatalogItem { accent: string; accentClassName?: string; @@ -46,6 +48,7 @@ interface IntegrationCatalogItem { iconPath: string; id: string; name: string; + productionReady?: boolean; } const SIMPLE_ICONS = { @@ -116,12 +119,12 @@ const GSC_ITEM: IntegrationCatalogItem = { description: "Use keyword rankings, impression drops, and CTR shifts in investigations.", iconPath: SIMPLE_ICONS.googlesearchconsole, - id: "google-search-console", + id: GOOGLE_SEARCH_CONSOLE_PROVIDER_ID, name: "Google Search Console", + productionReady: + INTEGRATION_PRODUCTION_READY[GOOGLE_SEARCH_CONSOLE_PROVIDER_ID], }; -const GSC_SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]; - const COMING_SOON_INTEGRATIONS: IntegrationCatalogItem[] = [ { accent: "#5E6AD2", @@ -217,8 +220,9 @@ function slackInstallUrl(organizationId: string): string { return url.toString(); } -function useLinkedAccounts() { +function useLinkedAccounts(enabled: boolean) { return useQuery({ + enabled, queryKey: ["linked-accounts"], queryFn: async () => { const result = await authClient.listAccounts(); @@ -227,14 +231,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 +259,29 @@ function useOAuthConnect(provider: string, scopes: string[], label: string) { }); } +function isIntegrationEnabled(item: IntegrationCatalogItem): boolean { + return ( + process.env.NODE_ENV !== "production" || item.productionReady !== false + ); +} + +function IntegrationUnavailableBadge() { + return ( + + Not ready + + ); +} + +function IntegrationUnavailableButton() { + return ( + + ); +} + function ConnectionBadge({ connected, loading, @@ -276,7 +312,7 @@ function ConnectionBadge({ function LoadingButton() { return ( - @@ -319,7 +355,8 @@ export function IntegrationsSettings({ const queryClient = useQueryClient(); const searchParams = useSearchParams(); const [pendingUninstall, setPendingUninstall] = - useState(null); + useState(null); + const slackEnabled = isIntegrationEnabled(SLACK_ITEM); const listKey = orpc.integrations.list.key({ input: { organizationId: organization.id }, }); @@ -338,6 +375,7 @@ export function IntegrationsSettings({ ...orpc.integrations.list.queryOptions({ input: { organizationId: organization.id }, }), + enabled: slackEnabled, }); const uninstallSlack = useMutation({ @@ -352,7 +390,7 @@ export function IntegrationsSettings({ }); const slackIntegrations = (integrationsQuery.data?.slack ?? - []) as SlackIntegration[]; + []) as SlackIntegrationOutput[]; return (
@@ -364,7 +402,7 @@ export function IntegrationsSettings({ />
-
+
Integrations @@ -395,7 +433,12 @@ export function IntegrationsSettings({ {COMING_SOON_INTEGRATIONS.map((item) => ( + @@ -435,29 +478,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 +525,7 @@ function GSCIntegrationRow() { } else { action = ( ); } @@ -481,19 +542,24 @@ function GSCIntegrationRow() { + enabled ? ( + + ) : ( + + ) } - item={GSC_ITEM} + item={item} /> ); } function GitHubIntegrationRow({ organizationId }: { organizationId: string }) { const queryClient = useQueryClient(); - const accounts = useLinkedAccounts(); + const enabled = isIntegrationEnabled(GITHUB_ITEM); + const accounts = useLinkedAccounts(enabled); const githubAccount = accounts.data?.find((a) => a.providerId === "github"); const canDisconnect = (accounts.data?.length ?? 0) > 1; @@ -501,7 +567,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 +615,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 && } void; + onUninstall: (integration: SlackIntegrationOutput) => void; organizationId: string; uninstallingId?: string; }) { + const enabled = isIntegrationEnabled(SLACK_ITEM); 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 +1077,10 @@ function SlackIntegrationRow({ } else { action = ( @@ -995,16 +1092,24 @@ function SlackIntegrationRow({ return ( } + badge={ + enabled ? ( + + ) : ( + + ) + } defaultOpen={false} item={SLACK_ITEM} > - + {enabled && ( + + )} ); } @@ -1045,12 +1150,14 @@ function IntegrationListRow({ if (!children) { return (
-
-
{header}
-
+
+
+ {header} +
+
{action}
@@ -1064,11 +1171,11 @@ function IntegrationListRow({ id={item.id} > -
+
{header} -
+
{action}
@@ -1103,9 +1210,9 @@ function SlackIntegrationDetails({ onUninstall, uninstallingIntegrationId, }: { - integrations: SlackIntegration[]; + integrations: SlackIntegrationOutput[]; isLoading: boolean; - onUninstall: (integration: SlackIntegration) => void; + onUninstall: (integration: SlackIntegrationOutput) => void; uninstallingIntegrationId?: string; }) { if (isLoading) { @@ -1160,7 +1267,7 @@ function SlackWorkspaceRow({ isUninstalling, onUninstall, }: { - integration: SlackIntegration; + integration: SlackIntegrationOutput; isUninstalling: boolean; onUninstall: () => void; }) { 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 ? ( - ) : ( <> -