From 31a0c50eb22b7ea1f90b4e0227b04b1631b4a0b4 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 6 May 2026 16:12:35 +0100 Subject: [PATCH] feat(code): unify GitHub connect to auto-pick team vs user flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Connect GitHub button across onboarding, settings, inbox, and task surfaces — the hook picks the team-level flow when the user is an org admin and the project has no team integration yet, otherwise the user-level flow. The team-level flow on PostHog Cloud also creates the admin's UserIntegration in the same round-trip, so admins finish onboarding with both records and proactive features start working immediately. Non-admins still get a working personal connection; features that require the team integration stay gated by hasGithubIntegration as before. Generated-By: PostHog Code Task-Id: 891f7f13-159c-4ae7-b7e9-cef32fe012b0 --- apps/code/src/renderer/App.tsx | 7 +- .../features/auth/hooks/useOrgRole.ts | 12 + .../inbox/components/DataSourceSetup.tsx | 7 +- .../list/GitHubConnectionBanner.tsx | 14 +- .../hooks/useGithubUserConnect.ts | 209 ++++++++++++++---- .../components/GitIntegrationStep.tsx | 17 +- .../sections/GitHubIntegrationSection.tsx | 7 +- .../components/CloudGithubMissingNotice.tsx | 11 +- 8 files changed, 225 insertions(+), 59 deletions(-) create mode 100644 apps/code/src/renderer/features/auth/hooks/useOrgRole.ts diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 4c93f2c99..91afbdf79 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -11,6 +11,7 @@ import { useCurrentUser, } from "@features/auth/hooks/authQueries"; import { useAuthSession } from "@features/auth/hooks/useAuthSession"; +import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { Flex, Spinner, Text } from "@radix-ui/themes"; @@ -32,8 +33,6 @@ import { Toaster } from "sonner"; const log = logger.scope("app"); -const ORGANIZATION_ADMIN_LEVEL = 8; - function App() { const trpcReact = useTRPC(); const { isBootstrapped } = useAuthSession(); @@ -182,8 +181,8 @@ function App() { hasCodeAccess === true && currentOrg != null && currentOrg.is_ai_data_processing_approved !== true; - const isAdmin = - (currentOrg?.membership_level ?? 0) >= ORGANIZATION_ADMIN_LEVEL; + const { isAdmin: isOrgAdmin } = useIsOrgAdmin(); + const isAdmin = isOrgAdmin === true; // Handle transition into main app — only show the dark overlay if dark mode is active useEffect(() => { diff --git a/apps/code/src/renderer/features/auth/hooks/useOrgRole.ts b/apps/code/src/renderer/features/auth/hooks/useOrgRole.ts new file mode 100644 index 000000000..09ece7ac4 --- /dev/null +++ b/apps/code/src/renderer/features/auth/hooks/useOrgRole.ts @@ -0,0 +1,12 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useCurrentUser } from "@features/auth/hooks/authQueries"; + +export const ORGANIZATION_ADMIN_LEVEL = 8; + +export function useIsOrgAdmin(): { isAdmin: boolean | null } { + const client = useOptionalAuthenticatedClient(); + const { data, isLoading } = useCurrentUser({ client }); + const level = data?.organization?.membership_level ?? null; + if (isLoading || level === null) return { isAdmin: null }; + return { isAdmin: level >= ORGANIZATION_ADMIN_LEVEL }; +} diff --git a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx index b3022342e..5d66bf75c 100644 --- a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx +++ b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx @@ -3,7 +3,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; import { describeGithubConnectError, - useGithubUserConnect, + useGithubConnect, } from "@features/integrations/hooks/useGithubUserConnect"; import { useGithubRepositories, @@ -86,7 +86,10 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { isTimedOut: timedOut, hasError: hasConnectError, connect: handleConnectGitHub, - } = useGithubUserConnect({ projectId }); + } = useGithubConnect({ + projectId, + projectHasTeamIntegration: hasGithubIntegration, + }); const selectedIntegrationId = repo ? getIntegrationIdForRepo(repo) : undefined; diff --git a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx index 449e33dee..4db9a02da 100644 --- a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx @@ -2,10 +2,13 @@ import { Button } from "@components/ui/Button"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { describeGithubConnectError, - useGithubUserConnect, + useGithubConnect, } from "@features/integrations/hooks/useGithubUserConnect"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; +import { + useRepositoryIntegration, + useUserRepositoryIntegration, +} from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, GithubLogoIcon, @@ -21,6 +24,8 @@ export function GitHubConnectionBanner() { ); const { hasGithubIntegration: hasGithubForProject } = useUserRepositoryIntegration(); + const { hasGithubIntegration: hasTeamGithubIntegration } = + useRepositoryIntegration(); const projectId = useAuthStateValue((s) => s.projectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); @@ -30,7 +35,10 @@ export function GitHubConnectionBanner() { hasError: hasConnectError, connect, reset, - } = useGithubUserConnect({ projectId }); + } = useGithubConnect({ + projectId, + projectHasTeamIntegration: hasTeamGithubIntegration, + }); const canConnectCloud = projectId != null && cloudRegion != null; if (loginLoading) { diff --git a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts index ca35e6908..4191fdd20 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts +++ b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts @@ -1,9 +1,12 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; import { useGitHubIntegrationCallback } from "@features/integrations/hooks/useGitHubIntegrationCallback"; +import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc/client"; import { IS_DEV } from "@shared/constants/environment"; import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; const POLL_INTERVAL_MS = 3_000; const POLL_TIMEOUT_MS = 300_000; @@ -96,8 +99,18 @@ export async function openUrlInBrowser(url: string): Promise { } } -export function useGithubUserConnect({ projectId }: Options): Result { - const client = useOptionalAuthenticatedClient(); +interface StateMachine { + state: GithubUserConnectState; + error: GithubUserConnectError | null; + stateRef: React.MutableRefObject; + beginConnecting: () => void; + finishWithError: (error: GithubUserConnectError) => void; + reset: () => void; + scheduleUserFlowTimeout: () => void; + scheduleDevPolling: () => void; +} + +function useConnectStateMachine(projectId: number | null): StateMachine { const queryClient = useQueryClient(); const [state, setState] = useState("idle"); const [error, setError] = useState(null); @@ -152,40 +165,20 @@ export function useGithubUserConnect({ projectId }: Options): Result { }, }); - const connect = useCallback(async () => { - if (stateRef.current === "connecting") return; - if (projectId === null || !client) return; + const beginConnecting = useCallback(() => { stopPolling(); setError(null); setState("connecting"); - try { - const res = await client.startGithubUserIntegrationConnect(projectId); - const installUrl = res.install_url?.trim() ?? ""; - if (!installUrl) { - throw new Error("GitHub connection did not return a URL"); - } - await openUrlInBrowser(installUrl); - - if (IS_DEV) { - pollTimerRef.current = setInterval( - () => invalidate(projectId), - POLL_INTERVAL_MS, - ); - } + }, [stopPolling]); - pollTimeoutRef.current = setTimeout(() => { - stopPolling(); - setState("timed-out"); - }, POLL_TIMEOUT_MS); - } catch (e) { + const finishWithError = useCallback( + (e: GithubUserConnectError) => { + stopPolling(); + setError(e); setState("error"); - setError({ - message: - e instanceof Error ? e.message : "Failed to start GitHub connection", - code: null, - }); - } - }, [client, projectId, invalidate, stopPolling]); + }, + [stopPolling], + ); const reset = useCallback(() => { stopPolling(); @@ -193,13 +186,153 @@ export function useGithubUserConnect({ projectId }: Options): Result { setState("idle"); }, [stopPolling]); + const scheduleUserFlowTimeout = useCallback(() => { + pollTimeoutRef.current = setTimeout(() => { + stopPolling(); + setState("timed-out"); + }, POLL_TIMEOUT_MS); + }, [stopPolling]); + + const scheduleDevPolling = useCallback(() => { + if (!IS_DEV) return; + pollTimerRef.current = setInterval( + () => invalidate(projectId), + POLL_INTERVAL_MS, + ); + }, [invalidate, projectId]); + + return useMemo( + () => ({ + state, + error, + stateRef, + beginConnecting, + finishWithError, + reset, + scheduleUserFlowTimeout, + scheduleDevPolling, + }), + [ + state, + error, + beginConnecting, + finishWithError, + reset, + scheduleUserFlowTimeout, + scheduleDevPolling, + ], + ); +} + +function machineToResult( + machine: StateMachine, + connect: () => Promise, +): Result { return { - state, - error, - isConnecting: state === "connecting", - isTimedOut: state === "timed-out", - hasError: state === "error", + state: machine.state, + error: machine.error, + isConnecting: machine.state === "connecting", + isTimedOut: machine.state === "timed-out", + hasError: machine.state === "error", connect, - reset, + reset: machine.reset, }; } + +async function runUserFlow( + client: PostHogAPIClient, + projectId: number, +): Promise { + const res = await client.startGithubUserIntegrationConnect(projectId); + const installUrl = res.install_url?.trim() ?? ""; + if (!installUrl) { + throw new Error("GitHub connection did not return a URL"); + } + await openUrlInBrowser(installUrl); +} + +export function useGithubUserConnect({ projectId }: Options): Result { + const client = useOptionalAuthenticatedClient(); + const machine = useConnectStateMachine(projectId); + + const connect = useCallback(async () => { + if (machine.stateRef.current === "connecting") return; + if (projectId === null || !client) return; + machine.beginConnecting(); + try { + await runUserFlow(client, projectId); + machine.scheduleDevPolling(); + machine.scheduleUserFlowTimeout(); + } catch (e) { + machine.finishWithError({ + message: + e instanceof Error ? e.message : "Failed to start GitHub connection", + code: null, + }); + } + }, [client, projectId, machine]); + + return machineToResult(machine, connect); +} + +interface ConnectOptions extends Options { + /** Whether `projectId` already has a team-level GitHub Integration. Required + * because the relevant project is not always the auth project (e.g. + * onboarding picks a project from a list). Admins on projects where this + * is `false` get the team-level OAuth flow (Cloud also seeds their + * `UserIntegration` in the same round-trip). */ + projectHasTeamIntegration: boolean | null; +} + +/** + * Single "Connect GitHub" button for surfaces that should respect the + * team-vs-user distinction. Picks the team-level flow only for admins on + * projects with no team integration yet; everyone else gets the user-level + * flow. For purely user-scoped surfaces ("Add another GitHub org") use + * `useGithubUserConnect` directly. + */ +export function useGithubConnect({ + projectId, + projectHasTeamIntegration, +}: ConnectOptions): Result { + const client = useOptionalAuthenticatedClient(); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const { isAdmin } = useIsOrgAdmin(); + const machine = useConnectStateMachine(projectId); + + const shouldUseTeamFlow = + isAdmin === true && + projectHasTeamIntegration === false && + cloudRegion != null; + + const connect = useCallback(async () => { + if (machine.stateRef.current === "connecting") return; + if (projectId === null || !client) return; + machine.beginConnecting(); + try { + if (shouldUseTeamFlow && cloudRegion) { + const res = await trpcClient.githubIntegration.startFlow.mutate({ + region: cloudRegion, + projectId, + }); + if (!res.success) { + throw new Error(res.error ?? "Failed to start GitHub connection"); + } + // Team flow's URL launch + timeout live in the main process and route + // back through the shared callback subscription. + } else { + await runUserFlow(client, projectId); + machine.scheduleDevPolling(); + machine.scheduleUserFlowTimeout(); + } + } catch (e) { + machine.finishWithError({ + message: + e instanceof Error ? e.message : "Failed to start GitHub connection", + code: null, + }); + } + }, [client, projectId, shouldUseTeamFlow, cloudRegion, machine]); + + return machineToResult(machine, connect); +} diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index e7aef796c..e1d5fa7f7 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -5,7 +5,7 @@ import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import { describeGithubConnectError, invalidateGithubQueries, - useGithubUserConnect, + useGithubConnect, } from "@features/integrations/hooks/useGithubUserConnect"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { @@ -83,6 +83,11 @@ export function GitIntegrationStep({ return currentProjectId ?? projects[0]?.id ?? null; }, [manuallySelectedProjectId, currentProjectId, projects]); + const selectedProject = useMemo( + () => projects.find((p) => p.id === selectedProjectId), + [projects, selectedProjectId], + ); + const { error: connectError, isConnecting, @@ -90,7 +95,10 @@ export function GitIntegrationStep({ hasError: hasConnectError, connect: handleConnectGitHub, reset: resetConnect, - } = useGithubUserConnect({ projectId: selectedProjectId }); + } = useGithubConnect({ + projectId: selectedProjectId, + projectHasTeamIntegration: selectedProject?.hasGithubIntegration ?? null, + }); const canTakeAction = !isConnecting && !timedOut && !hasConnectError; const defaultPanelMessage = hasConnectError ? describeGithubConnectError(connectError) @@ -100,11 +108,6 @@ export function GitIntegrationStep({ ? "Waiting for GitHub..." : "Optional. Unlocks cloud agents and pull request workflows."; - const selectedProject = useMemo( - () => projects.find((p) => p.id === selectedProjectId), - [projects, selectedProjectId], - ); - const { data: githubUserIntegrations = [], isLoading: githubUserIntegrationsLoading, diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx index 40298078d..a094d175d 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx @@ -1,7 +1,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { describeGithubConnectError, - useGithubUserConnect, + useGithubConnect, } from "@features/integrations/hooks/useGithubUserConnect"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { @@ -25,7 +25,10 @@ export function GitHubIntegrationSection({ isTimedOut: timedOut, hasError: hasConnectError, connect: handleConnect, - } = useGithubUserConnect({ projectId }); + } = useGithubConnect({ + projectId, + projectHasTeamIntegration: hasGithubIntegration, + }); return ( s.projectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); - const { error, isConnecting, hasError, connect, reset } = - useGithubUserConnect({ projectId }); + const { hasGithubIntegration: hasTeamGithubIntegration } = + useRepositoryIntegration(); + const { error, isConnecting, hasError, connect, reset } = useGithubConnect({ + projectId, + projectHasTeamIntegration: hasTeamGithubIntegration, + }); const canConnect = projectId != null && cloudRegion != null; return (