Files
supabase/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx
T
Joshen Lim 2b393377c6 Show connection settings if already configured despite no GH auth (#51427)
## Context

A project could already have a GitHub integration set up by another
member, which will show up in the branch management side navigation as
such:
<img width="259" height="255" alt="image"
src="https://github.com/user-attachments/assets/8bc12ef2-5f25-4245-9c89-4563b08a42a8"
/>

However if the user hasn't authenticated with GitHub, the project
settings -> integrations page will obfuscate the integration details
with a "Connect GitHub" CTA, which is a confusing UX as it hints that
there's no integration set up + conflicts with what's on the branch
management page
<img width="768" height="318" alt="image"
src="https://github.com/user-attachments/assets/a68409c0-f2db-407d-9bac-c109a0d14214"
/>

## Changes involved

Opting to show the integration details if there's one set up
irregardless of whether the user has authenticated with GH, but we'll
require the user to complete the authentication to manage the
integration settings as such (all input fields will also be disabled
until the auth is complete):
<img width="1031" height="511" alt="image"
src="https://github.com/user-attachments/assets/6fad387b-6e76-475e-b4e6-76972d8f76b6"
/>

Note that you can still disable the integration nonetheless irregardless
of the GH connection status

Also fixes the "Remove connection" CTA from Account Preferences - we
were invalidating the GH authorization query cache but the GH
authorization requests normally fails with a 404 if there's no auth set
up, so the query cache would remain stale. Opting to clear the query
cache instead of invalidating

## To test
- [ ] Set up a GH integration for your project under project settings ->
integrations
- [ ] Head to account preferences to remove your GH connection
- [ ] Go back to the project settings -> integrations page, should still
see the GH integration details, just unable to edit
- [ ] Connect with GH, and you should be able to edit the integration
details thereafter
2026-10-09 16:28:49 +08:00

754 lines
30 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { AnimatePresence, motion } from 'framer-motion'
import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardFooter,
cn,
Form,
FormControl,
FormField,
Input,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { ConnectGitHubButton } from './ConnectGitHubButton'
import {
GitHubRepositoryField,
useGitHubRepositoryOptions,
} from '@/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { InlineLink } from '@/components/ui/InlineLink'
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation'
import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
import { useBranchesQuery } from '@/data/branches/branches-query'
import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query'
import { useGitHubConnectionCreateMutation } from '@/data/integrations/github-connection-create-mutation'
import { useGitHubConnectionDeleteMutation } from '@/data/integrations/github-connection-delete-mutation'
import { useGitHubConnectionUpdateMutation } from '@/data/integrations/github-connection-update-mutation'
import type { GitHubConnection } from '@/data/integrations/integrations.types'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
interface GitHubIntegrationConnectionFormProps {
connection?: GitHubConnection
}
export const GitHubIntegrationConnectionForm = ({
connection,
}: GitHubIntegrationConnectionFormProps) => {
const { data: selectedProject } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const [isConfirmingBranchChange, setIsConfirmingBranchChange] = useState(false)
const [isConfirmingRepoChange, setIsConfirmingRepoChange] = useState(false)
const isParentProject = !selectedProject?.parent_project_ref
const { hasAccess: hasAccessToGitHubIntegration, isLoading: isLoadingEntitlements } =
useCheckEntitlements('integrations.github_connections')
const { hasAccess: hasAccessToBranching } = useCheckEntitlements('branching_limit')
const { can: canUpdateGitHubConnection } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'integrations.github_connections'
)
const { can: canCreateGitHubConnection } = useAsyncCheckPermissions(
PermissionAction.CREATE,
'integrations.github_connections'
)
const canManageGitHubConnection = canCreateGitHubConnection && canUpdateGitHubConnection
const {
gitHubAuthorization,
githubRepos,
hasPartialResponseDueToSSO,
isLoading: isLoadingRepositoryOptions,
refetch: refetchRepositoryOptions,
} = useGitHubRepositoryOptions()
const { mutateAsync: updateBranch } = useBranchUpdateMutation({
onSuccess: () => {
toast.success('Production branch settings successfully updated')
},
})
const { mutateAsync: createBranch } = useBranchCreateMutation({
onSuccess: () => {
toast.success('Production branch settings successfully updated')
},
onError: (error) => {
console.error('Failed to enable branching:', error)
},
})
const { data: existingBranches } = useBranchesQuery(
{ projectRef: selectedProject?.ref },
{ enabled: !!selectedProject?.ref }
)
const { mutateAsync: checkGithubBranchValidity, isPending: isCheckingBranch } =
useCheckGithubBranchValidity({ onError: () => {} })
const { mutateAsync: createConnection, isPending: isCreatingConnection } =
useGitHubConnectionCreateMutation({
onError: (error) => {
// Don't show error toast when connection already exists - the branch
// settings update will still proceed and show its own success toast
if (!error.message?.includes('already exists')) {
toast.error(`Failed to create GitHub connection: ${error.message}`)
}
},
})
const { mutateAsync: deleteConnection, isPending: isDeletingConnection } =
useGitHubConnectionDeleteMutation({
onSuccess: () => {
toast.success('GitHub connection removed')
},
})
const { mutateAsync: updateConnectionSettings, isPending: isUpdatingConnection } =
useGitHubConnectionUpdateMutation()
const prodBranch = existingBranches?.find((branch) => branch.is_default)
// Combined GitHub Settings Form
const GitHubSettingsSchema = z
.object({
repositoryId: z.string().min(1, 'Please select a repository'),
enableProductionSync: z.boolean().default(true),
branchName: z.string().default('main'),
new_branch_per_pr: z.boolean().default(true),
supabaseDirectory: z.string().default('.'),
supabaseChangesOnly: z.boolean().default(true),
branchLimit: z.string().default('50'),
})
.superRefine(async (val, ctx) => {
if (val.enableProductionSync && val.branchName && val.branchName.length > 0) {
const repositoryId = val.repositoryId || connection?.repository.id.toString()
if (repositoryId) {
try {
await checkGithubBranchValidity({
repositoryId: Number(repositoryId),
branchName: val.branchName,
})
} catch {
const selectedRepo = githubRepos.find((repo) => repo.id === repositoryId)
const repoName =
selectedRepo?.name || connection?.repository.name || 'selected repository'
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Branch "${val.branchName}" not found in ${repoName}`,
path: ['branchName'],
})
}
}
}
})
const hasGitBranch = Boolean(prodBranch?.git_branch?.trim())
const connectionValues: z.infer<typeof GitHubSettingsSchema> | undefined = connection
? {
repositoryId: connection.repository.id.toString(),
enableProductionSync: hasGitBranch,
branchName: hasGitBranch ? (prodBranch?.git_branch ?? '') : '',
new_branch_per_pr: connection.new_branch_per_pr,
supabaseDirectory: connection.workdir || '',
supabaseChangesOnly: connection.supabase_changes_only,
branchLimit: String(connection.branch_limit),
}
: undefined
const githubSettingsForm = useForm<z.infer<typeof GitHubSettingsSchema>>({
resolver: zodResolver(GitHubSettingsSchema),
mode: 'onSubmit',
reValidateMode: 'onBlur',
defaultValues: {
repositoryId: connection?.repository.id.toString() || '',
branchName: prodBranch?.git_branch || 'main',
enableProductionSync: true,
new_branch_per_pr: true,
supabaseDirectory: '.',
supabaseChangesOnly: true,
branchLimit: '3',
},
values: connectionValues,
resetOptions: { keepDirtyValues: true },
})
const enableProductionSync = useWatch({
control: githubSettingsForm.control,
name: 'enableProductionSync',
})
const newBranchPerPr = useWatch({
control: githubSettingsForm.control,
name: 'new_branch_per_pr',
})
const currentRepositoryId = useWatch({
control: githubSettingsForm.control,
name: 'repositoryId',
})
const handleCreateOrUpdateConnection = async (data: z.infer<typeof GitHubSettingsSchema>) => {
if (!selectedProject?.ref || !selectedOrganization?.id) return
try {
if (connection) {
// Check if repository is being changed
if (connection.repository.id.toString() !== data.repositoryId) {
setIsConfirmingRepoChange(true)
return
}
// Update existing connection
await handleUpdateConnection(data, connection)
} else {
// Create new connection
const selectedRepo = githubRepos.find((repo) => repo.id === data.repositoryId)
if (!selectedRepo) {
toast.error('Please select a repository')
return
}
await handleCreateConnection(data, selectedRepo)
}
} catch (error) {
console.error('Error managing connection:', error)
}
}
const handleCreateConnection = async (
data: z.infer<typeof GitHubSettingsSchema>,
selectedRepo: { id: string; installation_id: number }
) => {
if (!selectedProject?.ref || !selectedOrganization?.id) return
const connectionRequest = createConnection({
organizationId: selectedOrganization.id,
connection: {
installation_id: selectedRepo.installation_id,
project_ref: selectedProject.ref,
repository_id: Number(selectedRepo.id),
workdir: data.supabaseDirectory,
...(hasAccessToBranching && {
supabase_changes_only: data.supabaseChangesOnly,
branch_limit: Number(data.branchLimit),
new_branch_per_pr: data.new_branch_per_pr ?? false,
}),
},
})
const branchRequest = prodBranch
? updateBranch({
branchRef: prodBranch.project_ref,
projectRef: selectedProject.ref,
gitBranch: data.branchName,
})
: createBranch({
projectRef: selectedProject.ref,
branchName: 'main',
gitBranch: data.branchName,
is_default: true,
})
// Only re-baseline the form once both requests succeed, so a failure keeps the edits unsaved
const results = await Promise.allSettled([connectionRequest, branchRequest])
if (results.every((result) => result.status === 'fulfilled')) {
toast.success('GitHub connection updated')
githubSettingsForm.reset(data, { keepDirtyValues: false })
}
}
const handleUpdateConnection = async (
data: z.infer<typeof GitHubSettingsSchema>,
currentConnection: GitHubConnection
) => {
if (!selectedProject?.ref || !selectedOrganization?.id) return
const originalBranchName = prodBranch?.git_branch
if (originalBranchName && data.branchName !== originalBranchName && data.enableProductionSync) {
setIsConfirmingBranchChange(true)
return
}
await executeUpdate(data, currentConnection)
}
const executeUpdate = async (
data: z.infer<typeof GitHubSettingsSchema>,
currentConnection: GitHubConnection
) => {
if (!selectedProject?.ref || !selectedOrganization?.id) return
const connectionRequest = updateConnectionSettings({
connectionId: currentConnection.id,
organizationId: selectedOrganization.id,
connection: {
workdir: data.supabaseDirectory,
...(hasAccessToBranching && {
supabase_changes_only: data.supabaseChangesOnly,
branch_limit: Number(data.branchLimit),
new_branch_per_pr: data.new_branch_per_pr ?? false,
}),
},
})
// if for some reason, the project doesn't have a default branch yet, create it.
const branchRequest = prodBranch
? updateBranch({
branchRef: prodBranch.project_ref,
projectRef: selectedProject.ref,
gitBranch: data.enableProductionSync ? data.branchName : '',
branchName: data.branchName || 'main',
})
: createBranch({
projectRef: selectedProject.ref,
gitBranch: data.enableProductionSync ? data.branchName : '',
branchName: data.branchName || 'main',
is_default: true,
})
setIsConfirmingBranchChange(false)
// Only re-baseline the form once both requests succeed, so a failure keeps the edits unsaved
const results = await Promise.allSettled([connectionRequest, branchRequest])
if (results.every((result) => result.status === 'fulfilled')) {
githubSettingsForm.reset(data, { keepDirtyValues: false })
}
}
const onConfirmBranchChange = async () => {
if (connection) {
await executeUpdate(githubSettingsForm.getValues(), connection)
}
}
const handleRemoveIntegration = async () => {
if (!connection || !selectedOrganization?.id) return
try {
await deleteConnection({
organizationId: selectedOrganization.id,
connectionId: connection.id,
})
githubSettingsForm.reset(
{
repositoryId: '',
enableProductionSync: true,
branchName: 'main',
new_branch_per_pr: true,
supabaseDirectory: '.',
supabaseChangesOnly: true,
branchLimit: '3',
},
{ keepDirtyValues: false }
)
} catch (error) {
console.error('Error removing integration:', error)
toast.error('Failed to remove integration')
}
}
const onConfirmRepoChange = async () => {
const data = githubSettingsForm.getValues()
const selectedRepo = githubRepos.find((repo) => repo.id === data.repositoryId)
if (!selectedRepo || !connection || !selectedOrganization?.id) return
try {
await deleteConnection({
organizationId: selectedOrganization.id,
connectionId: connection.id,
})
await handleCreateConnection(data, selectedRepo)
setIsConfirmingRepoChange(false)
} catch (error) {
console.error('Error changing repository:', error)
toast.error('Failed to change repository')
}
}
const handleToggleProductionSync = (isEnabled: boolean) => {
githubSettingsForm.setValue('enableProductionSync', isEnabled, { shouldDirty: true })
githubSettingsForm.setValue('branchName', isEnabled ? prodBranch?.git_branch || 'main' : '', {
shouldDirty: true,
})
}
const isLoading =
isLoadingEntitlements ||
isCreatingConnection ||
isUpdatingConnection ||
isDeletingConnection ||
(!!gitHubAuthorization && isLoadingRepositoryOptions)
const isFieldDisabled = !canUpdateGitHubConnection || !gitHubAuthorization
let repositoryDescription = 'Select the repository to connect to your project'
if (connection) {
repositoryDescription = 'Change the connected repository'
} else if (gitHubAuthorization === null) {
repositoryDescription = 'Connect GitHub to link a repository to this project'
}
return (
<>
{!gitHubAuthorization && !!currentRepositoryId && (
<Admonition
className="mb-4"
layout="responsive"
title="Authorize GitHub to manage integration settings"
description="Required to list your repositories and change this project's connection"
actions={<ConnectGitHubButton refetch={refetchRepositoryOptions} />}
/>
)}
<Form {...githubSettingsForm}>
<form
onSubmit={githubSettingsForm.handleSubmit(handleCreateOrUpdateConnection)}
className={cn(!isParentProject && 'opacity-25 pointer-events-none')}
>
<Card>
<CardContent className="space-y-6">
<GitHubRepositoryField
form={githubSettingsForm}
name="repositoryId"
label="GitHub repository"
layout="flex-row-reverse"
description={repositoryDescription}
disabled={!gitHubAuthorization || !canManageGitHubConnection}
selectedRepositoryName={connection?.repository.name}
repositories={githubRepos}
gitHubAuthorization={gitHubAuthorization}
hasPartialResponseDueToSSO={hasPartialResponseDueToSSO}
isLoading={isLoadingRepositoryOptions}
refetch={refetchRepositoryOptions}
onRepositorySelect={(repo) => {
githubSettingsForm.setValue('branchName', repo.default_branch || 'main')
}}
/>
</CardContent>
<AnimatePresence>
{!!currentRepositoryId && (
<motion.div
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
>
<CardContent>
<FormField
control={githubSettingsForm.control}
name="supabaseDirectory"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Working directory"
description={
<>
Relative path to the directory containing your{' '}
<code className="text-code-inline whitespace-nowrap">supabase/</code>{' '}
folder.{' '}
<InlineLink
href={`${DOCS_URL}/guides/deployment/branching/github-integration#set-the-working-directory`}
>
Learn more
</InlineLink>
</>
}
>
<FormControl>
<Input
{...field}
placeholder="."
autoComplete="off"
disabled={isFieldDisabled}
/>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
{/* Production Branch Sync Section */}
<div className="space-y-4">
<FormField
control={githubSettingsForm.control}
name="enableProductionSync"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Deploy to production"
description="Apply changes to your production database when you merge into your configured production GitHub branch"
>
<FormControl>
<Switch
aria-label="Toggle deploy to production"
checked={field.value}
onCheckedChange={handleToggleProductionSync}
disabled={isFieldDisabled}
/>
</FormControl>
</FormItemLayout>
)}
/>
<div
className={cn(
'space-y-4 pl-6 border-l',
!enableProductionSync && 'opacity-25 pointer-events-none'
)}
>
<FormField
control={githubSettingsForm.control}
name="branchName"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Production branch name"
description="The GitHub branch to sync with your production database (e.g., main, master)"
>
<div className="relative w-full">
<FormControl>
<Input
{...field}
autoComplete="off"
disabled={isFieldDisabled || !enableProductionSync}
/>
</FormControl>
<div className="absolute top-2.5 right-3 flex items-center gap-2">
{isCheckingBranch && (
<Loader2 size={14} className="animate-spin" />
)}
</div>
</div>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
<CardContent>
{hasAccessToBranching ? (
<Admonition type="warning" title="Branching and billing" className="mb-4">
Branching Compute is not covered by your organization&apos;s Spend Cap.
Costs should be closely monitored, as they may be incurred.{' '}
<InlineLink
href={`${DOCS_URL}/guides/platform/cost-control#usage-items-not-covered-by-the-spend-cap`}
>
Learn more
</InlineLink>
</Admonition>
) : (
<UpgradeToPro
className="mb-4"
layout="vertical"
source="projectIntegrations"
featureProposition="automatically create preview branches from pull requests"
primaryText="Branching with GitHub integration"
secondaryText="Upgrade to the Pro Plan to enable branching and automatically create preview branches for every pull request"
docsUrl={`${DOCS_URL}/guides/deployment/branching`}
/>
)}
{/* Automatic Branching Section */}
<div className="space-y-4">
<FormField
disabled={!hasAccessToBranching}
control={githubSettingsForm.control}
name="new_branch_per_pr"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Automatic branching"
className={cn(!hasAccessToBranching && 'opacity-25')}
description="Create preview branches for every pull request"
>
<FormControl>
<Switch
aria-label="Toggle automatic branching"
checked={!hasAccessToBranching ? false : field.value}
onCheckedChange={field.onChange}
disabled={
!hasAccessToBranching ||
!canCreateGitHubConnection ||
isFieldDisabled
}
/>
</FormControl>
</FormItemLayout>
)}
/>
<div
className={cn(
'space-y-4 pl-6 border-l',
(!hasAccessToBranching || !newBranchPerPr) &&
'opacity-25 pointer-events-none'
)}
>
<FormField
control={githubSettingsForm.control}
name="branchLimit"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Branch limit"
description="Maximum number of preview branches"
>
<FormControl>
<Input
{...field}
type="number"
autoComplete="off"
value={!hasAccessToBranching ? 0 : field.value}
disabled={
!hasAccessToBranching ||
!newBranchPerPr ||
!canUpdateGitHubConnection ||
isFieldDisabled
}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={githubSettingsForm.control}
name="supabaseChangesOnly"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Supabase changes only"
description="Only create branches when Supabase files change"
>
<FormControl>
<Switch
aria-label="Toggle Supabase changes only"
checked={!hasAccessToBranching ? false : field.value}
onCheckedChange={(val) => field.onChange(val)}
disabled={
!hasAccessToBranching ||
!newBranchPerPr ||
!canUpdateGitHubConnection ||
isFieldDisabled
}
/>
</FormControl>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between items-center">
<div>
{connection && (
<Button
variant="outline"
onClick={handleRemoveIntegration}
disabled={isDeletingConnection || isCheckingBranch}
loading={isDeletingConnection}
>
Disable integration
</Button>
)}
</div>
<div className="flex space-x-2">
{githubSettingsForm.formState.isDirty && (
<Button
onClick={() =>
githubSettingsForm.reset(connectionValues, { keepDirtyValues: false })
}
disabled={!canUpdateGitHubConnection || isCheckingBranch}
>
Cancel
</Button>
)}
<ButtonTooltip
variant="primary"
type="submit"
disabled={
!hasAccessToGitHubIntegration ||
isCheckingBranch ||
isLoading ||
!canManageGitHubConnection ||
(!connection && !githubSettingsForm.getValues().repositoryId) ||
(connection && !githubSettingsForm.formState.isDirty)
}
loading={isLoading}
tooltip={{
content: {
side: 'bottom',
text: !canManageGitHubConnection
? "You need additional permissions to update this project's GitHub integration settings"
: undefined,
},
}}
>
{connection ? 'Save changes' : 'Enable integration'}
</ButtonTooltip>
</div>
</CardFooter>
</motion.div>
)}
</AnimatePresence>
</Card>
</form>
</Form>
<ConfirmationModal
variant="warning"
visible={isConfirmingBranchChange}
title="Changing production git branch"
confirmLabel="Confirm"
size="medium"
onCancel={() => setIsConfirmingBranchChange(false)}
onConfirm={onConfirmBranchChange}
loading={isUpdatingConnection}
>
<p className="text-sm text-foreground-light">
Open pull requests will only update your Supabase project on merge if the git base branch
matches this new production git branch.
</p>
</ConfirmationModal>
<ConfirmationModal
variant="warning"
visible={isConfirmingRepoChange}
title="Changing GitHub repository"
confirmLabel="Change repository"
size="medium"
onCancel={() => setIsConfirmingRepoChange(false)}
onConfirm={onConfirmRepoChange}
loading={isLoading}
>
<div className="space-y-3">
<p className="text-sm text-foreground-light">
This will disconnect your current repository and create a new connection with the
selected repository. All existing Supabase branches that are connected to the old
repository will no longer be synced.
</p>
</div>
</ConfirmationModal>
</>
)
}