import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useActionData, useLocation, useNavigate, useNavigation, useSearchParams, } from "@remix-run/react"; import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { GitBranchIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; import { z } from "zod"; import { EnvironmentIcon, environmentFullTitle, environmentTextClassName, } from "~/components/environments/EnvironmentLabel"; import { OctoKitty } from "~/components/GitHubLoginButton"; import { Button } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Select, SelectItem } from "~/components/primitives/Select"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { Switch } from "~/components/primitives/Switch"; import { TextLink } from "~/components/primitives/TextLink"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { redirectBackWithErrorMessage, redirectBackWithSuccessMessage, redirectWithErrorMessage, redirectWithSuccessMessage, } from "~/models/message.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { GitHubSettingsPresenter } from "~/presenters/v3/GitHubSettingsPresenter.server"; import { logger } from "~/services/logger.server"; import { triggerInitialDeployment } from "~/services/platform.v3.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; import { rbac } from "~/services/rbac.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { requireUserId } from "~/services/session.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, githubAppInstallPath, v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; import { type BranchTrackingConfig } from "~/v3/github"; // ============================================================================ // Types // ============================================================================ export type GitHubRepository = { id: string; name: string; fullName: string; private: boolean; htmlUrl: string; }; export type GitHubAppInstallation = { id: string; appInstallationId: bigint; targetType: string; accountHandle: string; repositories: GitHubRepository[]; }; export type ConnectedGitHubRepo = { branchTracking: BranchTrackingConfig | undefined; previewDeploymentsEnabled: boolean; createdAt: Date; repository: GitHubRepository; }; // ============================================================================ // Schemas // ============================================================================ export const ConnectGitHubRepoFormSchema = z.object({ action: z.literal("connect-repo"), installationId: z.string(), repositoryId: z.string(), redirectUrl: z.string().optional(), }); export const DisconnectGitHubRepoFormSchema = z.object({ action: z.literal("disconnect-repo"), redirectUrl: z.string().optional(), }); export const UpdateGitSettingsFormSchema = z.object({ action: z.literal("update-git-settings"), productionBranch: z.string().trim().optional(), stagingBranch: z.string().trim().optional(), previewDeploymentsEnabled: z .string() .optional() .transform((val) => val === "on"), redirectUrl: z.string().optional(), }); const GitHubActionSchema = z.discriminatedUnion("action", [ ConnectGitHubRepoFormSchema, DisconnectGitHubRepoFormSchema, UpdateGitSettingsFormSchema, ]); // ============================================================================ // Loader // ============================================================================ export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response("Not Found", { status: 404 }); } const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { throw new Response("Not Found", { status: 404 }); } const presenter = new GitHubSettingsPresenter(); const resultOrFail = await presenter.call({ projectId: project.id, organizationId: project.organizationId, }); if (resultOrFail.isErr()) { throw new Response("Failed to load GitHub settings", { status: 500 }); } // Display flag for the connect/disconnect/configure controls — the action // enforces write:github independently. Permissive in OSS. const sessionAuth = await rbac.authenticateSession(request, { userId, organizationId: project.organizationId, }); const canManageGithub = sessionAuth.ok ? sessionAuth.ability.can("write", { type: "github" }) : true; return typedjson({ ...resultOrFail.value, canManageGithub }); } // ============================================================================ // Action // ============================================================================ function redirectWithMessage( request: Request, redirectUrl: string | undefined, message: string, type: "success" | "error" ) { if (type === "success") { return redirectUrl ? redirectWithSuccessMessage(redirectUrl, request, message) : redirectBackWithSuccessMessage(request, message); } return redirectUrl ? redirectWithErrorMessage(redirectUrl, request, message) : redirectBackWithErrorMessage(request, message); } export const action = dashboardAction( { params: EnvironmentParamSchema, context: async (params) => { const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, authorization: { action: "write", resource: { type: "github" } }, }, async ({ request, params, user }) => { const userId = user.id; const { organizationSlug, projectParam, envParam } = params; const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response("Not Found", { status: 404 }); } const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { throw new Response("Not Found", { status: 404 }); } const formData = await request.formData(); const submission = parseWithZod(formData, { schema: GitHubActionSchema }); if (submission.status !== "success") { return json(submission.reply()); } const projectSettingsService = new ProjectSettingsService(); const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( organizationSlug, projectParam, userId ); if (membershipResultOrFail.isErr()) { return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); } const { projectId, organizationId } = membershipResultOrFail.value; const { action: actionType } = submission.value; // Handle connect-repo action if (actionType === "connect-repo") { const { repositoryId, installationId, redirectUrl } = submission.value; const resultOrFail = await projectSettingsService.connectGitHubRepo( projectId, organizationId, repositoryId, installationId ); if (resultOrFail.isOk()) { // Trigger initial deployment for marketplace flows now that GitHub is connected. // We check the persisted onboardingOrigin on the Vercel integration rather than // the redirectUrl, because the redirect URL loses the marketplace context when // the user installs the GitHub App for the first time (full-page redirect cycle). try { const vercelService = new VercelIntegrationService(); const vercelIntegration = await vercelService.getVercelProjectIntegration(projectId); if ( vercelIntegration?.parsedIntegrationData.onboardingCompleted && vercelIntegration.parsedIntegrationData.onboardingOrigin === "marketplace" ) { logger.info("Marketplace flow detected, triggering initial deployment", { projectId }); await triggerInitialDeployment(projectId, { environment: "prod" }); } } catch (error) { logger.error("Failed to check Vercel integration or trigger initial deployment", { projectId, error, }); } return redirectWithMessage( request, redirectUrl, "GitHub repository connected successfully", "success" ); } const errorType = resultOrFail.error.type; if (errorType === "gh_repository_not_found") { return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error"); } if (errorType === "project_already_has_connected_repository") { return redirectWithMessage( request, redirectUrl, "Project already has a connected repository", "error" ); } logger.error("Failed to connect GitHub repository", { error: resultOrFail.error }); return redirectWithMessage( request, redirectUrl, "Failed to connect GitHub repository", "error" ); } // Handle disconnect-repo action if (actionType === "disconnect-repo") { const { redirectUrl } = submission.value; const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId); if (resultOrFail.isOk()) { return redirectWithMessage( request, redirectUrl, "GitHub repository disconnected successfully", "success" ); } logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error }); return redirectWithMessage( request, redirectUrl, "Failed to disconnect GitHub repository", "error" ); } // Handle update-git-settings action if (actionType === "update-git-settings") { const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } = submission.value; const resultOrFail = await projectSettingsService.updateGitSettings( projectId, productionBranch, stagingBranch, previewDeploymentsEnabled ); if (resultOrFail.isOk()) { return redirectWithMessage( request, redirectUrl, "Git settings updated successfully", "success" ); } const errorType = resultOrFail.error.type; const errorMessages: Record = { github_app_not_enabled: "GitHub app is not enabled", connected_gh_repository_not_found: "Connected GitHub repository not found", production_tracking_branch_not_found: "Production tracking branch not found", staging_tracking_branch_not_found: "Staging tracking branch not found", }; const message = errorMessages[errorType]; if (message) { return redirectWithMessage(request, redirectUrl, message, "error"); } logger.error("Failed to update Git settings", { error: resultOrFail.error }); return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error"); } // Exhaustive check - this should never be reached submission.value satisfies never; return redirectBackWithErrorMessage(request, "Failed to process request"); } ); // ============================================================================ // Helper: Build resource URL for fetching GitHub data // ============================================================================ export function gitHubResourcePath( organizationSlug: string, projectSlug: string, environmentSlug: string ) { return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/github`; } // ============================================================================ // Components // ============================================================================ export function ConnectGitHubRepoModal({ gitHubAppInstallations, organizationSlug, projectSlug, environmentSlug, redirectUrl, preventDismiss, canManageGithub = true, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; projectSlug: string; environmentSlug: string; redirectUrl?: string; /** When true, prevents closing the modal via Escape key or clicking outside */ preventDismiss?: boolean; canManageGithub?: boolean; }) { const [isModalOpen, setIsModalOpen] = useState(false); const lastSubmission = useActionData() as any; const navigate = useNavigate(); const [selectedInstallation, setSelectedInstallation] = useState< GitHubAppInstallation | undefined >(gitHubAppInstallations.at(0)); const [selectedRepository, setSelectedRepository] = useState( undefined ); const navigation = useNavigation(); const isConnectRepositoryLoading = navigation.formData?.get("action") === "connect-repo" && (navigation.state === "submitting" || navigation.state === "loading"); const [form, { installationId, repositoryId }] = useForm({ id: "connect-repo", lastResult: lastSubmission, shouldRevalidate: "onSubmit", onValidate({ formData }) { return parseWithZod(formData, { schema: ConnectGitHubRepoFormSchema, }); }, }); const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { const params = new URLSearchParams(searchParams); if (params.get("openGithubRepoModal") === "1") { setIsModalOpen(true); params.delete("openGithubRepoModal"); setSearchParams(params); } }, [searchParams, setSearchParams]); useEffect(() => { if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) { setIsModalOpen(false); } }, [lastSubmission]); const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug); return ( { // When preventDismiss is true, only allow opening, not closing if (preventDismiss && !open) { return; } setIsModalOpen(open); }} > { if (preventDismiss) { e.preventDefault(); } }} onEscapeKeyDown={(e) => { if (preventDismiss) { e.preventDefault(); } }} > Connect GitHub repository
{redirectUrl && } Choose a GitHub repository to connect to your project.
{installationId.errors} Configure repository access in{" "} GitHub . {repositoryId.errors} {form.errors} Connect repository } cancelButton={ preventDismiss ? undefined : ( ) } />
); } export function GitHubConnectionPrompt({ gitHubAppInstallations, organizationSlug, projectSlug, environmentSlug, redirectUrl, canManageGithub = true, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; projectSlug: string; environmentSlug: string; redirectUrl?: string; canManageGithub?: boolean; }) { const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath( { slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug } ); return (
{gitHubAppInstallations.length === 0 && ( Install GitHub app )} {gitHubAppInstallations.length !== 0 && (
GitHub app is installed
)}
); } export function ConnectedGitHubRepoForm({ connectedGitHubRepo, previewEnvironmentEnabled, organizationSlug, projectSlug, environmentSlug, billingPath, redirectUrl, canManageGithub = true, }: { connectedGitHubRepo: ConnectedGitHubRepo; previewEnvironmentEnabled?: boolean; organizationSlug: string; projectSlug: string; environmentSlug: string; billingPath: string; redirectUrl?: string; canManageGithub?: boolean; }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false); const [gitSettingsValues, setGitSettingsValues] = useState({ productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "", stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "", previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled, }); useEffect(() => { const hasChanges = gitSettingsValues.productionBranch !== (connectedGitHubRepo.branchTracking?.prod?.branch || "") || gitSettingsValues.stagingBranch !== (connectedGitHubRepo.branchTracking?.staging?.branch || "") || gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled; setHasGitSettingsChanges(hasChanges); }, [gitSettingsValues, connectedGitHubRepo]); const [gitSettingsForm, fields] = useForm({ id: "update-git-settings", lastResult: lastSubmission, shouldRevalidate: "onSubmit", onValidate({ formData }) { return parseWithZod(formData, { schema: UpdateGitSettingsFormSchema, }); }, }); const isGitSettingsLoading = navigation.formData?.get("action") === "update-git-settings" && (navigation.state === "submitting" || navigation.state === "loading"); const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug); return ( <>
{connectedGitHubRepo.repository.fullName} {connectedGitHubRepo.repository.private && ( )}
Disconnect GitHub repository
Are you sure you want to disconnect{" "} {connectedGitHubRepo.repository.fullName}? This will stop automatic deployments from GitHub. {redirectUrl && } } cancelButton={ } />
{redirectUrl && }
Every push to the selected tracking branch creates a deployment in the corresponding environment.
{environmentFullTitle({ type: "PRODUCTION" })}
{ setGitSettingsValues((prev) => ({ ...prev, productionBranch: e.target.value, })); }} />
{environmentFullTitle({ type: "STAGING" })}
{ setGitSettingsValues((prev) => ({ ...prev, stagingBranch: e.target.value, })); }} />
{environmentFullTitle({ type: "PREVIEW" })}
{ setGitSettingsValues((prev) => ({ ...prev, previewDeploymentsEnabled: checked, })); }} /> {!previewEnvironmentEnabled && ( Upgrade your plan to enable preview branches } /> )}
{fields.productionBranch?.errors} {fields.stagingBranch?.errors} {fields.previewDeploymentsEnabled?.errors} {gitSettingsForm.errors}
Save } />
); } // ============================================================================ // Main GitHub Settings Panel Component // ============================================================================ export function GitHubSettingsPanel({ organizationSlug, projectSlug, environmentSlug, billingPath, }: { organizationSlug: string; projectSlug: string; environmentSlug: string; billingPath: string; }) { const fetcher = useTypedFetcher(); const location = useLocation(); // Preserve current search params (e.g. origin=marketplace, next=...) but strip // openGithubRepoModal so the modal doesn't re-open in a loop after the action redirect. const effectiveRedirectUrl = (() => { const params = new URLSearchParams(location.search); params.delete("openGithubRepoModal"); const search = params.toString(); return search ? `${location.pathname}?${search}` : location.pathname; })(); useEffect(() => { fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug)); }, [organizationSlug, projectSlug, environmentSlug]); const data = fetcher.data; // Loading state if (fetcher.state === "loading" && !data) { return (
Loading GitHub settings...
); } // GitHub app not enabled if (!data || !data.enabled) { return null; } // Connected repository exists - show form if (data.connectedRepository) { return ( ); } // No connected repository - show connection prompt return (
{!data.connectedRepository && ( Connect your GitHub repository to automatically deploy your changes. )}
); }