Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions apps/code/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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(() => {
Expand Down
12 changes: 12 additions & 0 deletions apps/code/src/renderer/features/auth/hooks/useOrgRole.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -96,8 +99,18 @@ export async function openUrlInBrowser(url: string): Promise<void> {
}
}

export function useGithubUserConnect({ projectId }: Options): Result {
const client = useOptionalAuthenticatedClient();
interface StateMachine {
state: GithubUserConnectState;
error: GithubUserConnectError | null;
stateRef: React.MutableRefObject<GithubUserConnectState>;
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<GithubUserConnectState>("idle");
const [error, setError] = useState<GithubUserConnectError | null>(null);
Expand Down Expand Up @@ -152,54 +165,174 @@ 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();
setError(null);
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<void>,
): 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<void> {
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -83,14 +83,22 @@ 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,
isTimedOut: timedOut,
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)
Expand All @@ -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,
Expand Down
Loading
Loading