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,