1002 lines
35 KiB
TypeScript
1002 lines
35 KiB
TypeScript
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<string, string> = {
|
|
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<GitHubRepository | undefined>(
|
|
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 (
|
|
<Dialog
|
|
open={isModalOpen}
|
|
onOpenChange={(open) => {
|
|
// When preventDismiss is true, only allow opening, not closing
|
|
if (preventDismiss && !open) {
|
|
return;
|
|
}
|
|
setIsModalOpen(open);
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant={"secondary/medium"}
|
|
LeadingIcon={OctoKitty}
|
|
disabled={!canManageGithub}
|
|
tooltip={
|
|
canManageGithub
|
|
? undefined
|
|
: "You don't have permission to manage the GitHub integration"
|
|
}
|
|
>
|
|
Connect GitHub repo
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent
|
|
showCloseButton={!preventDismiss}
|
|
onInteractOutside={(e) => {
|
|
if (preventDismiss) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
onEscapeKeyDown={(e) => {
|
|
if (preventDismiss) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
<DialogHeader>Connect GitHub repository</DialogHeader>
|
|
<div className="mt-2 flex flex-col gap-4">
|
|
<Form method="post" action={actionUrl} {...getFormProps(form)} className="w-full">
|
|
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
|
<Paragraph className="mb-3">
|
|
Choose a GitHub repository to connect to your project.
|
|
</Paragraph>
|
|
<Fieldset className="max-w-full gap-y-3">
|
|
<InputGroup className="max-w-full">
|
|
<Label htmlFor={installationId.id}>Account</Label>
|
|
<Select
|
|
name={installationId.name}
|
|
id={installationId.id}
|
|
value={selectedInstallation?.id}
|
|
defaultValue={gitHubAppInstallations.at(0)?.id}
|
|
setValue={(value) => {
|
|
if (Array.isArray(value)) return;
|
|
const installation = gitHubAppInstallations.find((i) => i.id === value);
|
|
setSelectedInstallation(installation);
|
|
setSelectedRepository(undefined);
|
|
}}
|
|
items={gitHubAppInstallations}
|
|
variant="tertiary/small"
|
|
placeholder="Select account"
|
|
dropdownIcon
|
|
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
|
|
>
|
|
{[
|
|
...gitHubAppInstallations.map((installation) => (
|
|
<SelectItem
|
|
key={installation.id}
|
|
value={installation.id}
|
|
icon={<OctoKitty className="size-3 text-text-dimmed" />}
|
|
>
|
|
{installation.accountHandle}
|
|
</SelectItem>
|
|
)),
|
|
<SelectItem
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
navigate(
|
|
githubAppInstallPath(
|
|
organizationSlug,
|
|
`${v3ProjectSettingsIntegrationsPath(
|
|
{ slug: organizationSlug },
|
|
{ slug: projectSlug },
|
|
{ slug: environmentSlug }
|
|
)}?openGithubRepoModal=1`
|
|
)
|
|
);
|
|
}}
|
|
key="new-account"
|
|
icon={<PlusIcon className="size-3 text-text-dimmed" />}
|
|
>
|
|
Add account
|
|
</SelectItem>,
|
|
]}
|
|
</Select>
|
|
<FormError id={installationId.errorId}>{installationId.errors}</FormError>
|
|
</InputGroup>
|
|
<InputGroup className="max-w-full">
|
|
<Label htmlFor={repositoryId.id}>Repository</Label>
|
|
<Select
|
|
name={repositoryId.name}
|
|
id={repositoryId.id}
|
|
value={selectedRepository ? selectedRepository.id : undefined}
|
|
setValue={(value) => {
|
|
if (Array.isArray(value)) return;
|
|
const repository = selectedInstallation?.repositories.find(
|
|
(r) => r.id === value
|
|
);
|
|
setSelectedRepository(repository);
|
|
}}
|
|
variant="tertiary/small"
|
|
placeholder="Select repository"
|
|
heading="Filter repositories"
|
|
dropdownIcon
|
|
items={selectedInstallation?.repositories ?? []}
|
|
filter={{ keys: ["name"] }}
|
|
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
|
|
text={selectedRepository ? selectedRepository.name : null}
|
|
>
|
|
{(matches) =>
|
|
matches.map((repo) => (
|
|
<SelectItem key={repo.id} value={repo.id}>
|
|
<div className="flex items-center gap-1">
|
|
{repo.name}
|
|
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
|
|
</div>
|
|
</SelectItem>
|
|
))
|
|
}
|
|
</Select>
|
|
<Hint className={cn("invisible", selectedInstallation && "visible")}>
|
|
Configure repository access in{" "}
|
|
<TextLink
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
|
|
>
|
|
GitHub
|
|
</TextLink>
|
|
.
|
|
</Hint>
|
|
<FormError id={repositoryId.errorId}>{repositoryId.errors}</FormError>
|
|
</InputGroup>
|
|
<FormError>{form.errors}</FormError>
|
|
<FormButtons
|
|
confirmButton={
|
|
<Button
|
|
type="submit"
|
|
name="action"
|
|
value="connect-repo"
|
|
variant="primary/medium"
|
|
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
|
|
leadingIconClassName="text-white"
|
|
disabled={isConnectRepositoryLoading}
|
|
>
|
|
Connect repository
|
|
</Button>
|
|
}
|
|
cancelButton={
|
|
preventDismiss ? undefined : (
|
|
<DialogClose asChild>
|
|
<Button variant="tertiary/medium">Cancel</Button>
|
|
</DialogClose>
|
|
)
|
|
}
|
|
/>
|
|
</Fieldset>
|
|
</Form>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Fieldset>
|
|
<InputGroup fullWidth>
|
|
{gitHubAppInstallations.length === 0 && (
|
|
<PermissionLink
|
|
hasPermission={canManageGithub}
|
|
noPermissionTooltip="You don't have permission to manage the GitHub integration"
|
|
to={githubAppInstallPath(
|
|
organizationSlug,
|
|
`${githubInstallationRedirect}?openGithubRepoModal=1`
|
|
)}
|
|
variant={"secondary/medium"}
|
|
LeadingIcon={OctoKitty}
|
|
>
|
|
Install GitHub app
|
|
</PermissionLink>
|
|
)}
|
|
{gitHubAppInstallations.length !== 0 && (
|
|
<div className="flex items-center gap-3">
|
|
<ConnectGitHubRepoModal
|
|
gitHubAppInstallations={gitHubAppInstallations}
|
|
organizationSlug={organizationSlug}
|
|
projectSlug={projectSlug}
|
|
environmentSlug={environmentSlug}
|
|
redirectUrl={redirectUrl}
|
|
canManageGithub={canManageGithub}
|
|
/>
|
|
<span className="flex items-center gap-1 text-xs text-text-dimmed">
|
|
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
|
|
</span>
|
|
</div>
|
|
)}
|
|
</InputGroup>
|
|
</Fieldset>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
|
|
<div className="flex items-center gap-2">
|
|
<OctoKitty className="size-4" />
|
|
<a
|
|
href={connectedGitHubRepo.repository.htmlUrl}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className="max-w-52 truncate text-sm text-text-bright hover:underline"
|
|
>
|
|
{connectedGitHubRepo.repository.fullName}
|
|
</a>
|
|
{connectedGitHubRepo.repository.private && (
|
|
<LockClosedIcon className="size-3 text-text-dimmed" />
|
|
)}
|
|
<span className="text-xs text-text-dimmed">
|
|
<DateTime
|
|
date={connectedGitHubRepo.createdAt}
|
|
includeTime={false}
|
|
includeSeconds={false}
|
|
showTimezone={false}
|
|
showTooltip={false}
|
|
/>
|
|
</span>
|
|
</div>
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="minimal/small"
|
|
disabled={!canManageGithub}
|
|
tooltip={
|
|
canManageGithub
|
|
? undefined
|
|
: "You don't have permission to manage the GitHub integration"
|
|
}
|
|
>
|
|
Disconnect
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>Disconnect GitHub repository</DialogHeader>
|
|
<div className="flex flex-col gap-3 pt-3">
|
|
<Paragraph className="mb-1">
|
|
Are you sure you want to disconnect{" "}
|
|
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
|
|
This will stop automatic deployments from GitHub.
|
|
</Paragraph>
|
|
<FormButtons
|
|
confirmButton={
|
|
<Form method="post" action={actionUrl}>
|
|
<input type="hidden" name="action" value="disconnect-repo" />
|
|
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
|
<Button type="submit" variant="danger/medium">
|
|
Disconnect repository
|
|
</Button>
|
|
</Form>
|
|
}
|
|
cancelButton={
|
|
<DialogClose asChild>
|
|
<Button variant="tertiary/medium">Cancel</Button>
|
|
</DialogClose>
|
|
}
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
<Form method="post" action={actionUrl} {...getFormProps(gitSettingsForm)}>
|
|
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
|
<Fieldset>
|
|
<InputGroup fullWidth>
|
|
<Hint>
|
|
Every push to the selected tracking branch creates a deployment in the corresponding
|
|
environment.
|
|
</Hint>
|
|
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
|
|
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
|
|
{environmentFullTitle({ type: "PRODUCTION" })}
|
|
</span>
|
|
</div>
|
|
<Input
|
|
{...getInputProps(fields.productionBranch, { type: "text" })}
|
|
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
|
|
placeholder="none"
|
|
variant="tertiary"
|
|
className="font-mono"
|
|
icon={GitBranchIcon}
|
|
onChange={(e) => {
|
|
setGitSettingsValues((prev) => ({
|
|
...prev,
|
|
productionBranch: e.target.value,
|
|
}));
|
|
}}
|
|
/>
|
|
<div className="flex items-center gap-1.5">
|
|
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
|
|
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
|
|
{environmentFullTitle({ type: "STAGING" })}
|
|
</span>
|
|
</div>
|
|
<Input
|
|
{...getInputProps(fields.stagingBranch, { type: "text" })}
|
|
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
|
|
placeholder="none"
|
|
variant="tertiary"
|
|
className="font-mono"
|
|
icon={GitBranchIcon}
|
|
onChange={(e) => {
|
|
setGitSettingsValues((prev) => ({
|
|
...prev,
|
|
stagingBranch: e.target.value,
|
|
}));
|
|
}}
|
|
/>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
|
|
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
|
|
{environmentFullTitle({ type: "PREVIEW" })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Switch
|
|
name="previewDeploymentsEnabled"
|
|
disabled={!previewEnvironmentEnabled}
|
|
defaultChecked={
|
|
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
|
|
}
|
|
variant="small"
|
|
label="Create preview deployments for pull requests"
|
|
labelPosition="right"
|
|
onCheckedChange={(checked) => {
|
|
setGitSettingsValues((prev) => ({
|
|
...prev,
|
|
previewDeploymentsEnabled: checked,
|
|
}));
|
|
}}
|
|
/>
|
|
{!previewEnvironmentEnabled && (
|
|
<InfoIconTooltip
|
|
content={
|
|
<span className="text-xs">
|
|
<TextLink to={billingPath}>Upgrade</TextLink> your plan to enable preview
|
|
branches
|
|
</span>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<FormError>{fields.productionBranch?.errors}</FormError>
|
|
<FormError>{fields.stagingBranch?.errors}</FormError>
|
|
<FormError>{fields.previewDeploymentsEnabled?.errors}</FormError>
|
|
<FormError>{gitSettingsForm.errors}</FormError>
|
|
</InputGroup>
|
|
|
|
<FormButtons
|
|
confirmButton={
|
|
<Button
|
|
type="submit"
|
|
name="action"
|
|
value="update-git-settings"
|
|
variant="secondary/small"
|
|
disabled={isGitSettingsLoading || !hasGitSettingsChanges || !canManageGithub}
|
|
tooltip={
|
|
canManageGithub
|
|
? undefined
|
|
: "You don't have permission to manage the GitHub integration"
|
|
}
|
|
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
|
|
>
|
|
Save
|
|
</Button>
|
|
}
|
|
/>
|
|
</Fieldset>
|
|
</Form>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main GitHub Settings Panel Component
|
|
// ============================================================================
|
|
|
|
export function GitHubSettingsPanel({
|
|
organizationSlug,
|
|
projectSlug,
|
|
environmentSlug,
|
|
billingPath,
|
|
}: {
|
|
organizationSlug: string;
|
|
projectSlug: string;
|
|
environmentSlug: string;
|
|
billingPath: string;
|
|
}) {
|
|
const fetcher = useTypedFetcher<typeof loader>();
|
|
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 (
|
|
<div className="flex items-center gap-2 text-text-dimmed">
|
|
<SpinnerWhite className="size-4" />
|
|
<span className="text-sm">Loading GitHub settings...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// GitHub app not enabled
|
|
if (!data || !data.enabled) {
|
|
return null;
|
|
}
|
|
|
|
// Connected repository exists - show form
|
|
if (data.connectedRepository) {
|
|
return (
|
|
<ConnectedGitHubRepoForm
|
|
connectedGitHubRepo={data.connectedRepository}
|
|
previewEnvironmentEnabled={data.isPreviewEnvironmentEnabled}
|
|
organizationSlug={organizationSlug}
|
|
projectSlug={projectSlug}
|
|
environmentSlug={environmentSlug}
|
|
billingPath={billingPath}
|
|
redirectUrl={effectiveRedirectUrl}
|
|
canManageGithub={data.canManageGithub}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// No connected repository - show connection prompt
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<GitHubConnectionPrompt
|
|
gitHubAppInstallations={data.installations ?? []}
|
|
organizationSlug={organizationSlug}
|
|
projectSlug={projectSlug}
|
|
environmentSlug={environmentSlug}
|
|
redirectUrl={effectiveRedirectUrl}
|
|
canManageGithub={data.canManageGithub}
|
|
/>
|
|
{!data.connectedRepository && (
|
|
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|