Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions components/backend/handlers/github_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,15 +358,18 @@ func GetGitHubInstallation(ctx context.Context, userID string) (*GitHubAppInstal
cm, err := K8sClient.CoreV1().ConfigMaps(Namespace).Get(ctx, cmName, v1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
log.Printf("GetGitHubInstallation: ConfigMap %s not found for user=%s", cmName, userID)
return nil, fmt.Errorf("installation not found")
}
return nil, fmt.Errorf("failed to read ConfigMap: %w", err)
}
if cm.Data == nil {
log.Printf("GetGitHubInstallation: no data in ConfigMap for user=%s", userID)
return nil, fmt.Errorf("installation not found")
}
raw, ok := cm.Data[userID]
if !ok || raw == "" {
log.Printf("GetGitHubInstallation: no entry for user=%s in ConfigMap", userID)
return nil, fmt.Errorf("installation not found")
}
var inst GitHubAppInstallation
Expand Down Expand Up @@ -395,27 +398,55 @@ func deleteGitHubInstallation(ctx context.Context, userID string) error {

// LinkGitHubInstallationGlobal handles POST /auth/github/install
// Links the current SSO user to a GitHub App installation ID.
// Accepts optional OAuth `code` for verified ownership via code exchange.
func LinkGitHubInstallationGlobal(c *gin.Context) {
userID, _ := c.Get("userID")
if userID == nil || strings.TrimSpace(userID.(string)) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user identity"})
return
}
userIDStr := userID.(string)
var req struct {
InstallationID int64 `json:"installationId" binding:"required"`
InstallationID int64 `json:"installationId" binding:"required"`
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("LinkGitHubInstallationGlobal: user=%s installationId=%d codePresent=%v", userIDStr, req.InstallationID, req.Code != "")
installation := GitHubAppInstallation{
UserID: userID.(string),
UserID: userIDStr,
InstallationID: req.InstallationID,
Host: "github.com",
UpdatedAt: time.Now(),
}
// Best-effort: enrich with GitHub account login for the installation
if GithubTokenManager != nil {

// If OAuth code is provided and OAuth env vars are configured, do verified ownership
clientID := os.Getenv("GITHUB_CLIENT_ID")
clientSecret := os.Getenv("GITHUB_CLIENT_SECRET")
if req.Code != "" && clientID != "" && clientSecret != "" {
token, err := exchangeOAuthCodeForUserToken(clientID, clientSecret, req.Code)
if err != nil {
log.Printf("LinkGitHubInstallationGlobal: OAuth code exchange failed for user=%s: %v", userIDStr, err)
// Fall through to best-effort enrichment below
} else {
owns, login, err := userOwnsInstallation(token, req.InstallationID)
if err != nil {
log.Printf("LinkGitHubInstallationGlobal: ownership verification failed for user=%s: %v", userIDStr, err)
} else if !owns {
log.Printf("LinkGitHubInstallationGlobal: user=%s does not own installation %d", userIDStr, req.InstallationID)
c.JSON(http.StatusForbidden, gin.H{"error": "installation not owned by user"})
return
} else {
log.Printf("LinkGitHubInstallationGlobal: verified ownership via OAuth for user=%s login=%s", userIDStr, login)
installation.GitHubUserID = login
}
}
}

// Best-effort: enrich with GitHub account login via App JWT if not already set
if installation.GitHubUserID == "" && GithubTokenManager != nil {
if jwt, err := GithubTokenManager.GenerateJWT(); err == nil {
api := githubAPIBaseURL(installation.Host)
url := fmt.Sprintf("%s/app/installations/%d", api, req.InstallationID)
Expand Down
5 changes: 5 additions & 0 deletions components/backend/handlers/integrations_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"context"
"log"
"net/http"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -44,6 +45,7 @@ func GetIntegrationsStatus(c *gin.Context) {
// Helper functions to get individual integration statuses

func getGitHubStatusForUser(ctx context.Context, userID string) gin.H {
log.Printf("getGitHubStatusForUser: querying status for user=%s", userID)
status := gin.H{
"installed": false,
"pat": gin.H{"configured": false},
Expand All @@ -52,11 +54,14 @@ func getGitHubStatusForUser(ctx context.Context, userID string) gin.H {
// Check GitHub App
inst, err := GetGitHubInstallation(ctx, userID)
if err == nil && inst != nil {
log.Printf("getGitHubStatusForUser: found installation for user=%s installationId=%d githubUser=%s", userID, inst.InstallationID, inst.GitHubUserID)
status["installed"] = true
status["installationId"] = inst.InstallationID
status["host"] = inst.Host
status["githubUserId"] = inst.GitHubUserID
status["updatedAt"] = inst.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
} else {
log.Printf("getGitHubStatusForUser: no installation found for user=%s", userID)
}

// Check GitHub PAT
Expand Down
33 changes: 19 additions & 14 deletions components/frontend/src/app/integrations/github/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,31 @@ export default function GitHubSetupPage() {
useEffect(() => {
const url = new URL(window.location.href)
const installationId = url.searchParams.get('installation_id')
const code = url.searchParams.get('code')

if (!installationId) {
setMessage('No installation was detected.')
return
}

connectMutation.mutate(
{ installationId: Number(installationId) },
{
onSuccess: () => {
setMessage('GitHub connected. Redirecting...')
setTimeout(() => {
window.location.replace('/integrations')
}, 800)
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Failed to complete setup')
},
}
)
const request: { installationId: number; code?: string } = {
installationId: Number(installationId),
}
if (code) {
request.code = code
}

connectMutation.mutate(request, {
onSuccess: () => {
setMessage('GitHub connected. Redirecting...')
setTimeout(() => {
window.location.replace('/integrations')
}, 800)
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Failed to complete setup')
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

Expand Down
7 changes: 4 additions & 3 deletions components/frontend/src/components/github-connection-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ export function GitHubConnectionCard({ appSlug, showManageButton = true, status,

const handleConnect = () => {
if (!appSlug) return
const setupUrl = new URL('/integrations/github/setup', window.location.origin)
const redirectUri = encodeURIComponent(setupUrl.toString())
const url = `https://github.com/apps/${appSlug}/installations/new?redirect_uri=${redirectUri}`
// Let GitHub use the Callback URL configured in the App settings
// rather than overriding with redirect_uri (which GitHub ignores
// unless it matches a configured callback URL)
const url = `https://github.com/apps/${appSlug}/installations/new`
window.location.href = url
}

Expand Down
2 changes: 1 addition & 1 deletion components/frontend/src/services/api/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function connectGitHub(data: GitHubConnectRequest): Promise<string>
'/auth/github/install',
data
);
return response.username;
return response.message;
}

/**
Expand Down
8 changes: 6 additions & 2 deletions components/frontend/src/services/queries/use-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ export function useConnectGitHub() {
return useMutation({
mutationFn: (data: GitHubConnectRequest) => githubApi.connectGitHub(data),
onSuccess: () => {
// Invalidate status to show connected state
// Invalidate both GitHub-specific and unified integrations status
queryClient.invalidateQueries({ queryKey: githubKeys.status() });
queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] });
},
});
}
Expand All @@ -91,8 +92,9 @@ export function useDisconnectGitHub() {
return useMutation({
mutationFn: githubApi.disconnectGitHub,
onSuccess: () => {
// Invalidate status to show disconnected state
// Invalidate both GitHub-specific and unified integrations status
queryClient.invalidateQueries({ queryKey: githubKeys.status() });
queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] });
// Clear forks cache
queryClient.invalidateQueries({ queryKey: githubKeys.forks() });
},
Expand Down Expand Up @@ -163,6 +165,7 @@ export function useSaveGitHubPAT() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...githubKeys.all, 'pat', 'status'] });
queryClient.invalidateQueries({ queryKey: githubKeys.status() });
queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] });
},
});
}
Expand All @@ -178,6 +181,7 @@ export function useDeleteGitHubPAT() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...githubKeys.all, 'pat', 'status'] });
queryClient.invalidateQueries({ queryKey: githubKeys.status() });
queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] });
},
});
}
4 changes: 1 addition & 3 deletions components/frontend/src/types/api/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,12 @@ export type CreatePRResponse = {

export type GitHubConnectRequest = {
installationId: number;
// Legacy OAuth fields (deprecated)
code?: string;
state?: string;
};

export type GitHubConnectResponse = {
message: string;
username: string;
installationId: number;
};

export type GitHubDisconnectResponse = {
Expand Down
Loading