From d23f08e561a47a06fc72ad6f2dc6e556efb40f3c Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 5 Nov 2025 11:58:37 +0100 Subject: [PATCH] feat: Add possibility to remove and reauthorize GitHub connections (#40126) --- .../Preferences/AccountConnections.tsx | 81 ++++++++++++++++++- .../GitHubIntegrationConnectionForm.tsx | 78 ++++++++++++------ .../github-authorization-create-mutation.ts | 12 ++- .../github-authorization-delete-mutation.ts | 48 +++++++++++ .../integrations/github-repositories-query.ts | 2 +- apps/studio/lib/github.ts | 2 +- packages/api-types/types/platform.d.ts | 44 +++++++++- 7 files changed, 233 insertions(+), 34 deletions(-) create mode 100644 apps/studio/data/integrations/github-authorization-delete-mutation.ts diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx index a8907fa8b35..5d7d4052125 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx @@ -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 ( {

-
+
{isConnected ? ( - Connected + <> + Connected + + + + + + { + event.preventDefault() + handleReauthenticate() + }} + > + +

Re-authenticate

+
+ setIsRemoveModalOpen(true)} + > + +

Remove connection

+
+
+
+ ) : (
)} + setIsRemoveModalOpen(false)} + onConfirm={handleRemove} + loading={isRemoving} + > +

+ Removing this authorization will disconnect your GitHub account from Supabase. You can + reconnect at any time. +

+
) } diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index 252894e8bbe..28b64e06930 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -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 = ({ No repositories found. - - {githubRepos.map((repo, i) => ( - { - field.onChange(repo.id) - setRepoComboboxOpen(false) - githubSettingsForm.setValue( - 'branchName', - repo.default_branch || 'main' - ) - }} - > -
- {GITHUB_ICON} -
- - {repo.name} - -
- ))} -
+ {githubRepos.length > 0 ? ( + + {githubRepos.map((repo, i) => ( + { + field.onChange(repo.id) + setRepoComboboxOpen(false) + githubSettingsForm.setValue( + 'branchName', + repo.default_branch || 'main' + ) + }} + > +
+ {GITHUB_ICON} +
+ + {repo.name} + +
+ ))} +
+ ) : null} + {hasPartialResponseDueToSSO && ( + <> + + + { + openInstallGitHubIntegrationWindow( + 'authorize', + refetchGitHubAuthorizationAndRepositories + ) + }} + > + +
+ Re-authorize GitHub with SSO to show all repositories +
+
+
+ + )}
diff --git a/apps/studio/data/integrations/github-authorization-create-mutation.ts b/apps/studio/data/integrations/github-authorization-create-mutation.ts index 3fd22101d17..18b1efea594 100644 --- a/apps/studio/data/integrations/github-authorization-create-mutation.ts +++ b/apps/studio/data/integrations/github-authorization-create-mutation.ts @@ -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) { diff --git a/apps/studio/data/integrations/github-authorization-delete-mutation.ts b/apps/studio/data/integrations/github-authorization-delete-mutation.ts new file mode 100644 index 00000000000..355ec5fc4f3 --- /dev/null +++ b/apps/studio/data/integrations/github-authorization-delete-mutation.ts @@ -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> + +export const useGitHubAuthorizationDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation({ + 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, + }) +} diff --git a/apps/studio/data/integrations/github-repositories-query.ts b/apps/studio/data/integrations/github-repositories-query.ts index 9ac089e0a2b..6d6739321ff 100644 --- a/apps/studio/data/integrations/github-repositories-query.ts +++ b/apps/studio/data/integrations/github-repositories-query.ts @@ -10,7 +10,7 @@ export async function getGitHubRepositories(signal?: AbortSignal) { }) if (error) handleError(error) - return data.repositories + return data } export type GitHubRepositoriesData = Awaited> diff --git a/apps/studio/lib/github.ts b/apps/studio/lib/github.ts index b78986c52e3..e961960cf0e 100644 --- a/apps/studio/lib/github.ts +++ b/apps/studio/lib/github.ts @@ -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 diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 2a9186388b7..91a27a413eb 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -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?: {