feat: Add possibility to remove and reauthorize GitHub connections (#40126)

This commit is contained in:
Luca Forstner authored and GitHub committed 2025-11-05 11:58:37 +01:00
1 parent 04bac966a8
commit d23f08e561
7 files changed
+233 -34

No files matched your search

@@ -1,10 +1,24 @@
import { ChevronDown, RefreshCw, Unlink } from 'lucide-react'
import Image from 'next/image'
import { useState } from 'react'
import { toast } from 'sonner'
import Panel from 'components/ui/Panel'
import { useGitHubAuthorizationDeleteMutation } from 'data/integrations/github-authorization-delete-mutation'
import { useGitHubAuthorizationQuery } from 'data/integrations/github-authorization-query'
import { BASE_PATH } from 'lib/constants'
import { openInstallGitHubIntegrationWindow } from 'lib/github'
import { Badge, Button, cn } from 'ui'
import {
Badge,
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
export const AccountConnections = () => {
@@ -16,12 +30,30 @@ export const AccountConnections = () => {
error,
} = useGitHubAuthorizationQuery()
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false)
const isConnected = gitHubAuthorization !== null
const { mutate: removeAuthorization, isLoading: isRemoving } =
useGitHubAuthorizationDeleteMutation({
onSuccess: () => {
toast.success('GitHub authorization removed successfully')
setIsRemoveModalOpen(false)
},
})
const handleConnect = () => {
openInstallGitHubIntegrationWindow('authorize')
}
const handleReauthenticate = () => {
openInstallGitHubIntegrationWindow('authorize')
}
const handleRemove = () => {
removeAuthorization()
}
return (
<Panel
className="mb-4 md:mb-8"
@@ -63,9 +95,37 @@ export const AccountConnections = () => {
</p>
</div>
</div>
<div className="flex items-center gap-x-1">
<div className="flex items-center gap-x-2 ml-2">
{isConnected ? (
<Badge variant="success">Connected</Badge>
<>
<Badge variant="success">Connected</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button iconRight={<ChevronDown size={14} />} type="default">
<span>Manage</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
className="space-x-2"
onSelect={(event) => {
event.preventDefault()
handleReauthenticate()
}}
>
<RefreshCw size={14} />
<p>Re-authenticate</p>
</DropdownMenuItem>
<DropdownMenuItem
className="space-x-2"
onSelect={() => setIsRemoveModalOpen(true)}
>
<Unlink size={14} />
<p>Remove connection</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<Button type="primary" onClick={handleConnect}>
Connect
@@ -74,6 +134,21 @@ export const AccountConnections = () => {
</div>
</Panel.Content>
)}
<ConfirmationModal
variant="destructive"
size="small"
visible={isRemoveModalOpen}
title="Confirm to remove GitHub authorization"
confirmLabel="Remove connection"
onCancel={() => setIsRemoveModalOpen(false)}
onConfirm={handleRemove}
loading={isRemoving}
>
<p className="text-sm text-foreground-light">
Removing this authorization will disconnect your GitHub account from Supabase. You can
reconnect at any time.
</p>
</ConfirmationModal>
</Panel>
)
}
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChevronDown, Loader2, PlusIcon } from 'lucide-react'
import { ChevronDown, Info, Loader2, PlusIcon, RefreshCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -33,6 +33,7 @@ import {
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
@@ -141,7 +142,7 @@ const GitHubIntegrationConnectionForm = ({
const githubRepos = useMemo(
() =>
githubReposData?.map((repo) => ({
githubReposData?.repositories?.map((repo) => ({
id: repo.id.toString(),
name: repo.name,
installation_id: repo.installation_id,
@@ -150,6 +151,8 @@ const GitHubIntegrationConnectionForm = ({
[githubReposData]
)
const hasPartialResponseDueToSSO = githubReposData?.partial_response_due_to_sso ?? false
const prodBranch = existingBranches?.find((branch) => branch.is_default)
// Combined GitHub Settings Form
@@ -474,30 +477,32 @@ const GitHubIntegrationConnectionForm = ({
<CommandInput_Shadcn_ placeholder="Search repositories..." />
<CommandList_Shadcn_ className="!max-h-[200px]">
<CommandEmpty_Shadcn_>No repositories found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{githubRepos.map((repo, i) => (
<CommandItem_Shadcn_
key={repo.id}
value={`${repo.name.replaceAll('"', '')}-${i}`}
className="flex gap-2 items-center"
onSelect={() => {
field.onChange(repo.id)
setRepoComboboxOpen(false)
githubSettingsForm.setValue(
'branchName',
repo.default_branch || 'main'
)
}}
>
<div className="bg-black shadow rounded p-1 w-5 h-5 flex justify-center items-center">
{GITHUB_ICON}
</div>
<span className="truncate" title={repo.name}>
{repo.name}
</span>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
{githubRepos.length > 0 ? (
<CommandGroup_Shadcn_>
{githubRepos.map((repo, i) => (
<CommandItem_Shadcn_
key={repo.id}
value={`${repo.name.replaceAll('"', '')}-${i}`}
className="flex gap-2 items-center"
onSelect={() => {
field.onChange(repo.id)
setRepoComboboxOpen(false)
githubSettingsForm.setValue(
'branchName',
repo.default_branch || 'main'
)
}}
>
<div className="bg-black shadow rounded p-1 w-5 h-5 flex justify-center items-center">
{GITHUB_ICON}
</div>
<span className="truncate" title={repo.name}>
{repo.name}
</span>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
) : null}
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
className="flex gap-2 items-center cursor-pointer"
@@ -512,6 +517,27 @@ const GitHubIntegrationConnectionForm = ({
Add GitHub Repositories
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
{hasPartialResponseDueToSSO && (
<>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
className="flex gap-2 items-start cursor-pointer"
onSelect={() => {
openInstallGitHubIntegrationWindow(
'authorize',
refetchGitHubAuthorizationAndRepositories
)
}}
>
<RefreshCw size={16} className="mt-0.5 shrink-0" />
<div className="text-xs text-foreground-light">
Re-authorize GitHub with SSO to show all repositories
</div>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
</>
)}
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
@@ -1,9 +1,10 @@
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { LOCAL_STORAGE_KEYS } from 'common'
import { handleError, post } from 'data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { integrationKeys } from './keys'
export type GitHubAuthorizationCreateVariables = {
code: string
@@ -44,6 +45,7 @@ export const useGitHubAuthorizationCreateMutation = ({
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
GitHubAuthorizationCreateData,
ResponseError,
@@ -51,6 +53,14 @@ export const useGitHubAuthorizationCreateMutation = ({
>({
mutationFn: (vars) => createGitHubAuthorization(vars),
async onSuccess(data, variables, context) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: integrationKeys.githubAuthorization(),
}),
queryClient.invalidateQueries({
queryKey: integrationKeys.githubRepositoriesList(),
}),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -0,0 +1,48 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { del, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { integrationKeys } from './keys'
export async function deleteGitHubAuthorization(signal?: AbortSignal) {
const { data, error } = await del('/platform/integrations/github/authorization', { signal })
if (error) handleError(error)
return data
}
type GitHubAuthorizationDeleteData = Awaited<ReturnType<typeof deleteGitHubAuthorization>>
export const useGitHubAuthorizationDeleteMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<GitHubAuthorizationDeleteData, ResponseError, void>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<GitHubAuthorizationDeleteData, ResponseError, void>({
mutationFn: () => deleteGitHubAuthorization(),
async onSuccess(data, variables, context) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: integrationKeys.githubAuthorization(),
}),
queryClient.invalidateQueries({
queryKey: integrationKeys.githubRepositoriesList(),
}),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to remove GitHub authorization: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -10,7 +10,7 @@ export async function getGitHubRepositories(signal?: AbortSignal) {
})
if (error) handleError(error)
return data.repositories
return data
}
export type GitHubRepositoriesData = Awaited<ReturnType<typeof getGitHubRepositories>>
+1 -1
View File
@@ -50,7 +50,7 @@ export function openInstallGitHubIntegrationWindow(
} else {
const state = makeRandomString(32)
localStorage.setItem(LOCAL_STORAGE_KEYS.GITHUB_AUTHORIZATION_STATE, state)
windowUrl = `${GITHUB_INTEGRATION_AUTHORIZATION_URL}&state=${state}`
windowUrl = `${GITHUB_INTEGRATION_AUTHORIZATION_URL}&state=${state}&prompt=select_account`
}
const systemZoom = width / window.screen.availWidth
+42 -2
View File
@@ -544,9 +544,16 @@ export interface paths {
/** Get GitHub authorization */
get: operations['GitHubAuthorizationsController_getGitHubAuthorization']
put?: never
/** Create GitHub authorization */
/**
* Upsert GitHub authorization
* @description Creates or updates a GitHub authorization for the current user
*/
post: operations['GitHubAuthorizationsController_createGitHubAuthorization']
delete?: never
/**
* Remove GitHub authorization
* @description Removes the GitHub authorization for the current user
*/
delete: operations['GitHubAuthorizationsController_removeGitHubAuthorization']
options?: never
head?: never
patch?: never
@@ -6992,6 +6999,8 @@ export interface components {
}[]
}
ListGitHubRepositoriesResponse: {
/** @description The authorized user may not have access to all GitHub repositories in case they haven't gone through the authorization process with SSO yet. This field will be `true` if this is the case. The calling user must reauthorize their GitHub account with SSO to see all repositories. */
partial_response_due_to_sso: boolean
repositories: {
default_branch: string
id: number
@@ -12130,6 +12139,37 @@ export interface operations {
}
}
}
GitHubAuthorizationsController_removeGitHubAuthorization: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description There was no GitHub authorization attached to the user */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Failed to remove GitHub authorization */
500: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
GitHubBranchesController_listConnectionBranches: {
parameters: {
query?: {