diff --git a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx index 204aeb10a4..91f5ff5fae 100644 --- a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx @@ -1,4 +1,3 @@ -import { useHostTRPC } from "@posthog/host-router/react"; import { Button } from "@posthog/quill"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; @@ -14,8 +13,7 @@ import { } from "@posthog/ui/features/integrations/useIntegrations"; import { toast } from "@posthog/ui/primitives/toast"; import { Box, Flex, Text, TextField } from "@radix-ui/themes"; -import { useMutation } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; type DataSourceType = "github" | "linear" | "jira" | "zendesk" | "pganalyze"; @@ -53,7 +51,15 @@ export function DataSourceSetup({ case "github": return ; case "linear": - return ; + return ( + + ); case "jira": return ( state.cloudRegion); - const projectId = useAuthStateValue((state) => state.currentProjectId); - const client = useAuthenticatedClient(); - const trpc = useHostTRPC(); - const [loading, setLoading] = useState(false); - const [oauthConnected, setOauthConnected] = useState(false); - const [linearIntegrationId, setLinearIntegrationId] = useState< - number | string | null - >(null); - const [pollError, setPollError] = useState(null); - const pollTimerRef = useRef | null>(null); - const pollTimeoutRef = useRef | null>(null); - - const stopPolling = useCallback(() => { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - if (pollTimeoutRef.current) { - clearTimeout(pollTimeoutRef.current); - pollTimeoutRef.current = null; - } - }, []); - - useEffect(() => stopPolling, [stopPolling]); - - const startLinearFlow = useMutation( - trpc.linearIntegration.startFlow.mutationOptions(), - ); - - const handleOAuthConnect = useCallback(async () => { - if (!cloudRegion || !projectId || !client) return; - setLoading(true); - setPollError(null); - try { - await startLinearFlow.mutateAsync({ - region: cloudRegion, - projectId, - }); - - pollTimerRef.current = setInterval(async () => { - try { - const integrations = - await client.getIntegrationsForProject(projectId); - const linearIntegration = integrations.find( - (i: { kind: string }) => i.kind === "linear", - ) as { id: number | string } | undefined; - if (linearIntegration) { - stopPolling(); - setLoading(false); - setOauthConnected(true); - setLinearIntegrationId(linearIntegration.id); - toast.success("Linear connected"); - } - } catch { - // Ignore individual poll failures - } - }, POLL_INTERVAL_MS); - - pollTimeoutRef.current = setTimeout(() => { - stopPolling(); - setLoading(false); - setPollError("Connection timed out. Please try again."); - }, POLL_TIMEOUT_MS); - } catch (error) { - setLoading(false); - toast.error( - error instanceof Error ? error.message : "Failed to connect Linear", - ); - } - }, [cloudRegion, projectId, client, stopPolling, startLinearFlow]); - - const handleSubmit = useCallback(async () => { - if (!projectId || !client || !linearIntegrationId) return; - - setLoading(true); - try { - await client.createExternalDataSource(projectId, { - source_type: "Linear", - payload: { - linear_integration_id: linearIntegrationId, - schemas: schemasPayload("linear"), - }, - }); - toast.success("Linear data source created"); - onComplete(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to create data source", - ); - } finally { - setLoading(false); - } - }, [projectId, client, linearIntegrationId, onComplete]); - - return ( - - - - - {pollError && ( - {pollError} - )} - - - - - - - ); -} - function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx index 9cc24b2482..7a28467066 100644 --- a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -2,7 +2,9 @@ import type { SourceConfig, SourceFieldConfig, SourceFieldInputConfig, + SourceFieldOauthConfig, } from "@posthog/api-client/posthog-client"; +import { useHostTRPC } from "@posthog/host-router/react"; import { Button } from "@posthog/quill"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; @@ -17,7 +19,8 @@ import { TextArea, TextField, } from "@radix-ui/themes"; -import { useCallback, useMemo, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; interface SchemaPayload { name: string; @@ -35,7 +38,12 @@ interface DynamicSourceSetupProps { onCancel: () => void; } -type FieldValues = Record; +type FieldValue = string | number | boolean; +type FieldValues = Record; + +/** Poll cadence/ceiling for discovering the integration created by an OAuth grant. */ +const POLL_INTERVAL_MS = 3_000; +const POLL_TIMEOUT_MS = 5 * 60 * 1000; const INPUT_TYPES = new Set([ "text", @@ -56,15 +64,11 @@ function isInputField( } /** - * A field type the generic renderer cannot handle inline (OAuth grants, SSH - * tunnels, file uploads). Sources requiring these still need a bespoke form. + * A field type the generic renderer cannot handle inline (SSH tunnels, file + * uploads). Sources requiring these still need a bespoke form. */ function isUnsupportedField(field: SourceFieldConfig): boolean { - return ( - field.type === "oauth" || - field.type === "ssh-tunnel" || - field.type === "file-upload" - ); + return field.type === "ssh-tunnel" || field.type === "file-upload"; } /** @@ -90,6 +94,10 @@ function missingRequiredFields( } const option = field.options.find((o) => o.value === selected); if (option?.fields) walk(option.fields); + } else if (field.type === "oauth") { + if (field.required && !values[field.name]) { + missing.push(field.name); + } } else if (isInputField(field) && field.required) { const value = values[field.name]; if (typeof value !== "string" || value.trim().length === 0) { @@ -123,6 +131,9 @@ function buildPayload( selection: selected, ...(option?.fields ? collect(option.fields) : {}), }; + } else if (field.type === "oauth") { + const value = values[field.name]; + if (value !== undefined && value !== "") out[field.name] = value; } else if (isInputField(field)) { const value = values[field.name]; if (typeof value === "string") out[field.name] = value.trim(); @@ -146,7 +157,7 @@ export function DynamicSourceSetup({ const [values, setValues] = useState({}); const [submitting, setSubmitting] = useState(false); - const setValue = useCallback((name: string, value: string | boolean) => { + const setValue = useCallback((name: string, value: FieldValue) => { setValues((prev) => ({ ...prev, [name]: value })); }, []); @@ -207,6 +218,7 @@ export function DynamicSourceSetup({ field={field} values={values} setValue={setValue} + providerName={title.replace(/^Connect\s+/i, "")} /> ))} {hasUnsupportedField && ( @@ -244,10 +256,12 @@ function SourceField({ field, values, setValue, + providerName, }: { field: SourceFieldConfig; values: FieldValues; - setValue: (name: string, value: string | boolean) => void; + setValue: (name: string, value: FieldValue) => void; + providerName: string; }) { if (field.type === "switch-group") { const enabled = !!values[field.name]; @@ -270,12 +284,24 @@ function SourceField({ field={nested} values={values} setValue={setValue} + providerName={providerName} /> ))} ); } + if (field.type === "oauth") { + return ( + + ); + } + if (field.type === "select") { const selected = (values[field.name] as string) ?? field.defaultValue ?? ""; const option = field.options.find((o) => o.value === selected); @@ -301,6 +327,7 @@ function SourceField({ field={nested} values={values} setValue={setValue} + providerName={providerName} /> ))} @@ -337,6 +364,126 @@ function SourceField({ return null; } +/** + * Renders an `oauth` config field: a connect button that launches the provider's + * OAuth flow, polls for the resulting integration, and writes its id into the + * form. Mirrors the previous bespoke Linear setup. Only providers with a wired + * flow starter (currently `linear`) can be connected here; others surface a + * message. + */ +function OAuthSourceField({ + field, + value, + setValue, + providerName, +}: { + field: SourceFieldOauthConfig; + value: FieldValue | undefined; + setValue: (name: string, value: FieldValue) => void; + providerName: string; +}) { + const region = useAuthStateValue((state) => state.cloudRegion); + const projectId = useAuthStateValue((state) => state.currentProjectId); + const client = useAuthenticatedClient(); + const trpc = useHostTRPC(); + const startLinearFlow = useMutation( + trpc.linearIntegration.startFlow.mutationOptions(), + ); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + const pollTimer = useRef | null>(null); + const pollTimeout = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (pollTimer.current) { + clearInterval(pollTimer.current); + pollTimer.current = null; + } + if (pollTimeout.current) { + clearTimeout(pollTimeout.current); + pollTimeout.current = null; + } + }, []); + useEffect(() => stopPolling, [stopPolling]); + + const connected = value !== undefined && value !== ""; + + const startFlow = useCallback(async () => { + if (field.kind === "linear") { + if (!region || !projectId) throw new Error("Missing project context"); + await startLinearFlow.mutateAsync({ region, projectId }); + return; + } + throw new Error(`Connecting ${providerName} isn't supported here yet.`); + }, [field.kind, region, projectId, startLinearFlow, providerName]); + + const handleConnect = useCallback(async () => { + if (!projectId || !client) return; + setConnecting(true); + setError(null); + try { + await startFlow(); + pollTimer.current = setInterval(async () => { + try { + const integrations = + await client.getIntegrationsForProject(projectId); + const match = integrations.find( + (i: { kind: string }) => i.kind === field.kind, + ) as { id: number | string } | undefined; + if (match) { + stopPolling(); + setConnecting(false); + setValue(field.name, match.id); + toast.success(`${providerName} connected`); + } + } catch { + // Ignore individual poll failures; the timeout below bounds the wait. + } + }, POLL_INTERVAL_MS); + pollTimeout.current = setTimeout(() => { + stopPolling(); + setConnecting(false); + setError("Connection timed out. Please try again."); + }, POLL_TIMEOUT_MS); + } catch (err) { + setConnecting(false); + setError( + err instanceof Error + ? err.message + : `Failed to connect ${providerName}`, + ); + } + }, [ + projectId, + client, + startFlow, + field.kind, + field.name, + setValue, + providerName, + stopPolling, + ]); + + return ( + + + {error && {error}} + + ); +} + function SetupFormContainer({ title, children,