diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts
index f2209456c6..a8bcb3623d 100644
--- a/apps/code/src/main/trpc/router.ts
+++ b/apps/code/src/main/trpc/router.ts
@@ -26,6 +26,7 @@ import { gitRouter } from "@posthog/host-router/routers/git.router";
import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router";
import { githubReleasesRouter } from "@posthog/host-router/routers/github-releases.router";
import { handoffRouter } from "@posthog/host-router/routers/handoff.router";
+import { integrationRouter } from "@posthog/host-router/routers/integration.router";
import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router";
import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router";
import { localMcpRouter } from "@posthog/host-router/routers/local-mcp.router";
@@ -87,6 +88,7 @@ export const trpcRouter = router({
githubIntegration: githubIntegrationRouter,
githubReleases: githubReleasesRouter,
handoff: handoffRouter,
+ integration: integrationRouter,
linearIntegration: linearIntegrationRouter,
llmGateway: llmGatewayRouter,
localMcp: localMcpRouter,
diff --git a/apps/mobile/src/features/inbox/components/FilterSheet.tsx b/apps/mobile/src/features/inbox/components/FilterSheet.tsx
index 5243c67648..83163b2547 100644
--- a/apps/mobile/src/features/inbox/components/FilterSheet.tsx
+++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx
@@ -126,8 +126,12 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) {
const toggleStatus = useInboxFilterStore((s) => s.toggleStatus);
const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter);
const toggleSourceProduct = useInboxFilterStore((s) => s.toggleSourceProduct);
+ const clearSourceProductFilter = useInboxFilterStore(
+ (s) => s.clearSourceProductFilter,
+ );
const priorityFilter = useInboxFilterStore((s) => s.priorityFilter);
const togglePriority = useInboxFilterStore((s) => s.togglePriority);
+ const setPriorityFilter = useInboxFilterStore((s) => s.setPriorityFilter);
const resetFilters = useInboxFilterStore((s) => s.resetFilters);
const hasActiveFilters =
@@ -213,6 +217,11 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) {
{/* Priority */}
+ setPriorityFilter([])}
+ />
{FILTERABLE_PRIORITIES.map((priority) => (
+
{SOURCE_PRODUCT_OPTIONS.map((option) => (
{
expect(useInboxFilterStore.getState().sourceProductFilter).toEqual([]);
},
);
+
+ it("clears the source filter", () => {
+ const { toggleSourceProduct, clearSourceProductFilter } =
+ useInboxFilterStore.getState();
+
+ toggleSourceProduct("github");
+ toggleSourceProduct("linear");
+ expect(useInboxFilterStore.getState().sourceProductFilter).toEqual([
+ "github",
+ "linear",
+ ]);
+
+ clearSourceProductFilter();
+ expect(useInboxFilterStore.getState().sourceProductFilter).toEqual([]);
+ });
});
const INITIAL_STATE = useInboxFilterStore.getState();
diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts
index ea363b4e42..ab97b29525 100644
--- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts
+++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts
@@ -47,6 +47,7 @@ interface InboxFilterActions {
setStatusFilter: (statuses: SignalReportStatus[]) => void;
toggleStatus: (status: SignalReportStatus) => void;
toggleSourceProduct: (source: SourceProduct) => void;
+ clearSourceProductFilter: () => void;
toggleSuggestedReviewer: (reviewerUuid: string) => void;
setSuggestedReviewerFilter: (reviewerUuids: string[]) => void;
togglePriority: (priority: SignalReportPriority) => void;
@@ -85,6 +86,7 @@ export const useInboxFilterStore = create()(
: [...current, source];
return { sourceProductFilter: next };
}),
+ clearSourceProductFilter: () => set({ sourceProductFilter: [] }),
toggleSuggestedReviewer: (reviewerUuid) =>
set((state) => {
const current = state.suggestedReviewerFilter;
diff --git a/apps/mobile/src/features/tasks/api.test.ts b/apps/mobile/src/features/tasks/api.test.ts
index 19e6f93dc1..2ac4f23d57 100644
--- a/apps/mobile/src/features/tasks/api.test.ts
+++ b/apps/mobile/src/features/tasks/api.test.ts
@@ -63,6 +63,30 @@ describe("runTaskInCloud", () => {
expect(init.body).toBeUndefined();
});
+ it("forwards the selected sandbox environment and custom image", async () => {
+ await runTaskInCloud("task-1", {
+ sandboxEnvironmentId: "environment-123",
+ customImageId: "image-123",
+ });
+
+ expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({
+ sandbox_environment_id: "environment-123",
+ custom_image_id: "image-123",
+ });
+ });
+
+ it("omits the sandbox environment and custom image when unset", async () => {
+ await runTaskInCloud("task-1", {
+ model: "claude-opus-4-8",
+ sandboxEnvironmentId: null,
+ customImageId: null,
+ });
+
+ const body = bodyOf(mockFetch.mock.calls[0]);
+ expect(body).not.toHaveProperty("sandbox_environment_id");
+ expect(body).not.toHaveProperty("custom_image_id");
+ });
+
it("sends rtk_enabled=false when the run opts out", async () => {
await runTaskInCloud("task-1", { rtkEnabled: false });
diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts
index 6952fde9f6..fa703b7401 100644
--- a/apps/mobile/src/features/tasks/api.ts
+++ b/apps/mobile/src/features/tasks/api.ts
@@ -314,6 +314,8 @@ export async function warmTask(options: {
runtime_adapter?: string | null;
model?: string | null;
reasoning_effort?: string | null;
+ sandbox_environment_id?: string | null;
+ custom_image_id?: string | null;
}): Promise<{ task_id: string; run_id: string } | null> {
const baseUrl = getBaseUrl();
const projectId = getProjectId();
@@ -329,6 +331,12 @@ export async function warmTask(options: {
runtime_adapter: options.runtime_adapter ?? null,
model: options.model ?? null,
reasoning_effort: options.reasoning_effort ?? null,
+ ...(options.sandbox_environment_id
+ ? { sandbox_environment_id: options.sandbox_environment_id }
+ : {}),
+ ...(options.custom_image_id
+ ? { custom_image_id: options.custom_image_id }
+ : {}),
}),
},
);
@@ -431,6 +439,10 @@ export interface RunTaskInCloudOptions {
model?: string;
/** Reasoning effort: "low" | "medium" | "high" (model-dependent). */
reasoningEffort?: string;
+ /** Sandbox environment / custom base image to run on. Sent so a reused warm
+ * sandbox matches the selection instead of a mismatched default. */
+ sandboxEnvironmentId?: string | null;
+ customImageId?: string | null;
/** Permission mode: "default" | "acceptEdits" | "plan" | "auto". */
initialPermissionMode?: string;
/** Source that triggered this run. */
@@ -463,6 +475,8 @@ export async function runTaskInCloud(
options.runtimeAdapter !== undefined ||
options.model !== undefined ||
options.reasoningEffort !== undefined ||
+ options.sandboxEnvironmentId !== undefined ||
+ options.customImageId !== undefined ||
options.initialPermissionMode !== undefined ||
options.runSource !== undefined ||
options.signalReportId !== undefined ||
@@ -488,6 +502,12 @@ export async function runTaskInCloud(
payload.reasoning_effort = options.reasoningEffort;
}
}
+ if (options?.sandboxEnvironmentId) {
+ payload.sandbox_environment_id = options.sandboxEnvironmentId;
+ }
+ if (options?.customImageId) {
+ payload.custom_image_id = options.customImageId;
+ }
if (options?.initialPermissionMode) {
payload.initial_permission_mode = options.initialPermissionMode;
}
diff --git a/apps/mobile/src/features/tasks/api.warm.test.ts b/apps/mobile/src/features/tasks/api.warm.test.ts
index d8e8f882ee..06c9d1aba0 100644
--- a/apps/mobile/src/features/tasks/api.warm.test.ts
+++ b/apps/mobile/src/features/tasks/api.warm.test.ts
@@ -90,6 +90,58 @@ describe("warmTask", () => {
);
});
+ it("forwards the selected sandbox environment and custom image", async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), {
+ status: 200,
+ }),
+ );
+
+ await warmTask({
+ repository: "posthog/posthog",
+ github_integration: 7,
+ branch: "main",
+ sandbox_environment_id: "environment-123",
+ custom_image_id: "image-123",
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://app.posthog.test/api/projects/42/tasks/warm/",
+ expect.objectContaining({
+ body: JSON.stringify({
+ repository: "posthog/posthog",
+ github_integration: 7,
+ branch: "main",
+ runtime_adapter: null,
+ model: null,
+ reasoning_effort: null,
+ sandbox_environment_id: "environment-123",
+ custom_image_id: "image-123",
+ }),
+ }),
+ );
+ });
+
+ it("omits the sandbox environment and custom image when unset", async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), {
+ status: 200,
+ }),
+ );
+
+ await warmTask({
+ repository: "posthog/posthog",
+ github_integration: 7,
+ sandbox_environment_id: null,
+ custom_image_id: null,
+ });
+
+ const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(init.body as string);
+ expect(body).not.toHaveProperty("sandbox_environment_id");
+ expect(body).not.toHaveProperty("custom_image_id");
+ });
+
it("serializes a missing branch as null", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), {
diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx
index 8ed33df816..5b757d40fc 100644
--- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx
+++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx
@@ -32,6 +32,8 @@ interface Props {
runtimeAdapter?: string | null;
model?: string | null;
reasoningEffort?: string | null;
+ sandboxEnvironmentId?: string | null;
+ customImageId?: string | null;
}
const composing: Props = {
@@ -198,6 +200,42 @@ describe("useWarmTask", () => {
},
);
+ it("forwards the sandbox environment and custom image", async () => {
+ render({
+ ...composing,
+ sandboxEnvironmentId: "environment-123",
+ customImageId: "image-123",
+ });
+ await flushDebounce();
+
+ expect(mockWarmTask).toHaveBeenCalledWith({
+ repository: "acme/repo",
+ github_integration: 42,
+ branch: "main",
+ ...NULL_RUNTIME,
+ sandbox_environment_id: "environment-123",
+ custom_image_id: "image-123",
+ });
+ });
+
+ it("re-warms when the custom image changes", async () => {
+ const { rerender } = render({ ...composing, customImageId: "image-123" });
+ await flushDebounce();
+ expect(mockWarmTask).toHaveBeenCalledOnce();
+
+ rerender({ ...composing, customImageId: "image-456" });
+ await flushDebounce();
+
+ expect(mockWarmTask).toHaveBeenCalledTimes(2);
+ expect(mockWarmTask).toHaveBeenLastCalledWith({
+ repository: "acme/repo",
+ github_integration: 42,
+ branch: "main",
+ ...NULL_RUNTIME,
+ custom_image_id: "image-456",
+ });
+ });
+
it("warms again for a new selection after a failed warm", async () => {
mockWarmTask.mockRejectedValueOnce(new Error("boom"));
const { rerender } = render(composing);
diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts
index e69ce6555c..935619f75f 100644
--- a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts
+++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts
@@ -16,6 +16,8 @@ interface UseWarmTaskOptions {
runtimeAdapter?: string | null;
model?: string | null;
reasoningEffort?: string | null;
+ sandboxEnvironmentId?: string | null;
+ customImageId?: string | null;
}
export function useWarmTask({
@@ -26,6 +28,8 @@ export function useWarmTask({
runtimeAdapter,
model,
reasoningEffort,
+ sandboxEnvironmentId,
+ customImageId,
}: UseWarmTaskOptions): void {
const enabled = useFeatureFlag(TASKS_PREWARM_SANDBOX_FLAG);
@@ -36,6 +40,8 @@ export function useWarmTask({
const normalizedRuntimeAdapter = runtimeAdapter ?? null;
const normalizedModel = model ?? null;
const normalizedReasoningEffort = reasoningEffort ?? null;
+ const normalizedSandboxEnvironmentId = sandboxEnvironmentId ?? null;
+ const normalizedCustomImageId = customImageId ?? null;
const eligible =
!!enabled &&
!!repository &&
@@ -43,7 +49,7 @@ export function useWarmTask({
!composerIsEmpty;
const key =
repository && githubIntegrationId != null
- ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}`
+ ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}:${normalizedSandboxEnvironmentId ?? ""}:${normalizedCustomImageId ?? ""}`
: null;
useEffect(() => {
@@ -68,6 +74,8 @@ export function useWarmTask({
const warmRuntimeAdapter = normalizedRuntimeAdapter;
const warmModel = normalizedModel;
const warmReasoningEffort = normalizedReasoningEffort;
+ const warmSandboxEnvironmentId = normalizedSandboxEnvironmentId;
+ const warmCustomImageId = normalizedCustomImageId;
debounceRef.current = setTimeout(() => {
debounceRef.current = null;
lastWarmedKeyRef.current = key;
@@ -78,6 +86,10 @@ export function useWarmTask({
runtime_adapter: warmRuntimeAdapter,
model: warmModel,
reasoning_effort: warmReasoningEffort,
+ ...(warmSandboxEnvironmentId
+ ? { sandbox_environment_id: warmSandboxEnvironmentId }
+ : {}),
+ ...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}),
}).catch((error) => {
lastWarmedKeyRef.current = null;
log.warn("Failed to warm task", error);
@@ -94,5 +106,7 @@ export function useWarmTask({
normalizedRuntimeAdapter,
normalizedModel,
normalizedReasoningEffort,
+ normalizedSandboxEnvironmentId,
+ normalizedCustomImageId,
]);
}
diff --git a/packages/core/src/integrations/identifiers.ts b/packages/core/src/integrations/identifiers.ts
index a71c99eb94..d76d4a9b48 100644
--- a/packages/core/src/integrations/identifiers.ts
+++ b/packages/core/src/integrations/identifiers.ts
@@ -1,3 +1,7 @@
+export const INTEGRATION_SERVICE = Symbol.for(
+ "posthog.core.integrationService",
+);
+
export const GITHUB_INTEGRATION_SERVICE = Symbol.for(
"posthog.core.githubIntegrationService",
);
diff --git a/packages/core/src/integrations/integration.test.ts b/packages/core/src/integrations/integration.test.ts
new file mode 100644
index 0000000000..27a8a03805
--- /dev/null
+++ b/packages/core/src/integrations/integration.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it, vi } from "vitest";
+import { IntegrationService } from "./integration";
+
+function createService() {
+ const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) };
+ const service = new IntegrationService(urlLauncher as never);
+ return { service, urlLauncher };
+}
+
+describe("IntegrationService.startFlow", () => {
+ it("launches an authorize URL for the given kind scoped to the project", async () => {
+ const { service, urlLauncher } = createService();
+
+ const result = await service.startFlow("intercom", "us", 42);
+
+ expect(result).toEqual({ success: true });
+ const launched = urlLauncher.launch.mock.calls[0][0];
+ expect(launched).toContain("/api/environments/42/integrations/authorize/");
+ expect(launched).toContain("kind=intercom");
+ });
+
+ it("url-encodes the kind", async () => {
+ const { service, urlLauncher } = createService();
+
+ await service.startFlow("rapid7_insightvm", "eu", 7);
+
+ expect(urlLauncher.launch.mock.calls[0][0]).toContain(
+ "kind=rapid7_insightvm",
+ );
+ });
+
+ it("returns a failure result when launching the browser throws", async () => {
+ const { service, urlLauncher } = createService();
+ urlLauncher.launch.mockRejectedValue(new Error("no browser"));
+
+ expect(await service.startFlow("hubspot", "us", 42)).toEqual({
+ success: false,
+ error: "no browser",
+ });
+ });
+});
diff --git a/packages/core/src/integrations/integration.ts b/packages/core/src/integrations/integration.ts
new file mode 100644
index 0000000000..0a3cb19c60
--- /dev/null
+++ b/packages/core/src/integrations/integration.ts
@@ -0,0 +1,44 @@
+import {
+ type IUrlLauncher,
+ URL_LAUNCHER_SERVICE,
+} from "@posthog/platform/url-launcher";
+import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared";
+import { inject, injectable } from "inversify";
+import type { StartIntegrationFlowOutput } from "./schemas";
+
+/**
+ * Generic OAuth integration flow starter. PostHog's
+ * `…/integrations/authorize/?kind=` endpoint is generic over the integration kind, so a
+ * single service starts the flow for any supported OAuth provider (linear, intercom, hubspot,
+ * salesforce, …) — no per-kind service or router required. The OAuth grant, callback, and token
+ * storage all happen on PostHog Cloud; the caller then polls the integrations list for the new
+ * integration of this `kind`.
+ */
+@injectable()
+export class IntegrationService {
+ constructor(
+ @inject(URL_LAUNCHER_SERVICE)
+ private readonly urlLauncher: IUrlLauncher,
+ ) {}
+
+ public async startFlow(
+ kind: string,
+ region: CloudRegion,
+ projectId: number,
+ ): Promise {
+ try {
+ const cloudUrl = getCloudUrlFromRegion(region);
+ const next = `${cloudUrl}/project/${projectId}`;
+ const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=${encodeURIComponent(kind)}&next=${encodeURIComponent(next)}`;
+
+ await this.urlLauncher.launch(authorizeUrl);
+
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+ }
+}
diff --git a/packages/core/src/integrations/integrations.module.ts b/packages/core/src/integrations/integrations.module.ts
index bd57929177..3c3af8ab5f 100644
--- a/packages/core/src/integrations/integrations.module.ts
+++ b/packages/core/src/integrations/integrations.module.ts
@@ -2,13 +2,16 @@ import { ContainerModule } from "inversify";
import { GitHubIntegrationService } from "./github";
import {
GITHUB_INTEGRATION_SERVICE,
+ INTEGRATION_SERVICE,
LINEAR_INTEGRATION_SERVICE,
SLACK_INTEGRATION_SERVICE,
} from "./identifiers";
+import { IntegrationService } from "./integration";
import { LinearIntegrationService } from "./linear";
import { SlackIntegrationService } from "./slack";
export const integrationsModule = new ContainerModule(({ bind }) => {
+ bind(INTEGRATION_SERVICE).to(IntegrationService).inSingletonScope();
bind(GITHUB_INTEGRATION_SERVICE)
.to(GitHubIntegrationService)
.inSingletonScope();
diff --git a/packages/core/src/integrations/schemas.ts b/packages/core/src/integrations/schemas.ts
index f2d3220591..b58c263f68 100644
--- a/packages/core/src/integrations/schemas.ts
+++ b/packages/core/src/integrations/schemas.ts
@@ -11,6 +11,20 @@ export type StartIntegrationFlowInput = z.infer<
typeof startIntegrationFlowInput
>;
+/**
+ * Generic integration flow input: any OAuth `kind` PostHog supports. The per-kind routers
+ * (linear/slack/github) keep the narrower input above; this one drives the generic starter so
+ * new OAuth sources need no dedicated router.
+ */
+export const startGenericIntegrationFlowInput = z.object({
+ kind: z.string(),
+ region: cloudRegion,
+ projectId: z.number(),
+});
+export type StartGenericIntegrationFlowInput = z.infer<
+ typeof startGenericIntegrationFlowInput
+>;
+
export const startIntegrationFlowOutput = z.object({
success: z.boolean(),
error: z.string().optional(),
diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts
index a9582f4172..17d323b1eb 100644
--- a/packages/host-router/src/router.ts
+++ b/packages/host-router/src/router.ts
@@ -25,6 +25,7 @@ import { gitRouter } from "./routers/git.router";
import { githubIntegrationRouter } from "./routers/github-integration.router";
import { githubReleasesRouter } from "./routers/github-releases.router";
import { handoffRouter } from "./routers/handoff.router";
+import { integrationRouter } from "./routers/integration.router";
import { linearIntegrationRouter } from "./routers/linear-integration.router";
import { llmGatewayRouter } from "./routers/llm-gateway.router";
import { localMcpRouter } from "./routers/local-mcp.router";
@@ -76,6 +77,7 @@ export const hostRouter = router({
fs: fsRouter,
git: gitRouter,
handoff: handoffRouter,
+ integration: integrationRouter,
githubIntegration: githubIntegrationRouter,
githubReleases: githubReleasesRouter,
linearIntegration: linearIntegrationRouter,
diff --git a/packages/host-router/src/routers/integration.router.ts b/packages/host-router/src/routers/integration.router.ts
new file mode 100644
index 0000000000..680980f01c
--- /dev/null
+++ b/packages/host-router/src/routers/integration.router.ts
@@ -0,0 +1,23 @@
+import { INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers";
+import type { IntegrationService } from "@posthog/core/integrations/integration";
+import {
+ startGenericIntegrationFlowInput,
+ startIntegrationFlowOutput,
+} from "@posthog/core/integrations/schemas";
+import { publicProcedure, router } from "@posthog/host-trpc/trpc";
+
+/**
+ * Generic OAuth integration flow starter, parameterized by `kind`. Replaces the need for a
+ * per-provider router when adding a new OAuth data source — the source's connect-form schema
+ * already carries the `kind`, so the UI passes it straight through.
+ */
+export const integrationRouter = router({
+ startFlow: publicProcedure
+ .input(startGenericIntegrationFlowInput)
+ .output(startIntegrationFlowOutput)
+ .mutation(({ ctx, input }) => {
+ return ctx.container
+ .get(INTEGRATION_SERVICE)
+ .startFlow(input.kind, input.region, input.projectId);
+ }),
+});
diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx
index afe1e5c36d..c5d57650c4 100644
--- a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx
+++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx
@@ -399,9 +399,9 @@ function SourceField({
/**
* 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.
+ * form. The flow is started generically by the field's `kind` (PostHog's
+ * `…/integrations/authorize/?kind=…` endpoint is generic), so any OAuth source
+ * PostHog supports works here without provider-specific code.
*/
function OAuthSourceField({
field,
@@ -418,8 +418,8 @@ function OAuthSourceField({
const projectId = useAuthStateValue((state) => state.currentProjectId);
const client = useAuthenticatedClient();
const trpc = useHostTRPC();
- const startLinearFlow = useMutation(
- trpc.linearIntegration.startFlow.mutationOptions(),
+ const startIntegrationFlow = useMutation(
+ trpc.integration.startFlow.mutationOptions(),
);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState(null);
@@ -441,13 +441,16 @@ function OAuthSourceField({
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;
+ if (!region || !projectId) throw new Error("Missing project context");
+ const result = await startIntegrationFlow.mutateAsync({
+ kind: field.kind,
+ region,
+ projectId,
+ });
+ if (!result.success) {
+ throw new Error(result.error ?? `Failed to connect ${providerName}`);
}
- throw new Error(`Connecting ${providerName} isn't supported here yet.`);
- }, [field.kind, region, projectId, startLinearFlow, providerName]);
+ }, [field.kind, region, projectId, startIntegrationFlow, providerName]);
const handleConnect = useCallback(async () => {
if (!projectId || !client) return;