diff --git a/apps/web/app/api/github/orgs/install-status/route.ts b/apps/web/app/api/github/orgs/install-status/route.ts index 4b2b5a522..70c59bc83 100644 --- a/apps/web/app/api/github/orgs/install-status/route.ts +++ b/apps/web/app/api/github/orgs/install-status/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getGitHubAccount } from "@/lib/db/accounts"; import { getInstallationsByUserId } from "@/lib/db/installations"; import { isGitHubAppConfigured } from "@/lib/github/app-auth"; import { getInstallationManageUrl } from "@/lib/github/installation-url"; @@ -17,32 +18,39 @@ interface GitHubUser { avatar_url: string; } +export interface GitHubUserProfile { + githubId: number; + login: string; + avatarUrl: string; +} + export interface OrgInstallStatus { - /** Numeric GitHub account/org ID, used for target_id in install URLs */ githubId: number; login: string; avatarUrl: string; - type: "User" | "Organization"; installStatus: "installed" | "not_installed"; installationId: number | null; installationUrl: string | null; repositorySelection: "all" | "selected" | null; } +export interface ConnectionStatusResponse { + user: GitHubUserProfile; + /** Whether the user's personal account has the app installed */ + personalInstallStatus: "installed" | "not_installed"; + personalInstallationUrl: string | null; + personalRepositorySelection: "all" | "selected" | null; + orgs: OrgInstallStatus[]; + /** True when the GitHub token is expired and the data is from the DB cache */ + tokenExpired?: boolean; +} + export async function GET() { const session = await getServerSession(); if (!session?.user?.id) { return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); } - const token = await getUserGitHubToken(); - if (!token) { - return NextResponse.json( - { error: "GitHub not connected" }, - { status: 401 }, - ); - } - if (!isGitHubAppConfigured()) { return NextResponse.json( { error: "GitHub App not configured" }, @@ -50,6 +58,72 @@ export async function GET() { ); } + const token = await getUserGitHubToken(); + console.log("install-status: token resolved", { + userId: session.user.id, + hasToken: !!token, + }); + + // When the token is expired/invalid, fall back to DB-cached data so the UI + // can still show the connected user and prompt to reconnect. + if (!token) { + const [ghAccount, installations] = await Promise.all([ + getGitHubAccount(session.user.id), + getInstallationsByUserId(session.user.id), + ]); + + if (!ghAccount) { + return NextResponse.json( + { error: "GitHub not connected" }, + { status: 401 }, + ); + } + + const personalInstallation = installations.find( + (i) => i.accountLogin.toLowerCase() === ghAccount.username.toLowerCase(), + ); + + const orgs: OrgInstallStatus[] = installations + .filter( + (i) => + i.accountLogin.toLowerCase() !== ghAccount.username.toLowerCase(), + ) + .map((i) => ({ + githubId: 0, + login: i.accountLogin, + avatarUrl: "", + installStatus: "installed" as const, + installationId: i.installationId, + installationUrl: getInstallationManageUrl( + i.installationId, + i.installationUrl, + ), + repositorySelection: i.repositorySelection, + })); + + const response: ConnectionStatusResponse = { + user: { + githubId: Number(ghAccount.externalUserId) || 0, + login: ghAccount.username, + avatarUrl: `https://avatars.githubusercontent.com/u/${ghAccount.externalUserId}?v=4`, + }, + personalInstallStatus: personalInstallation + ? "installed" + : "not_installed", + personalInstallationUrl: personalInstallation + ? getInstallationManageUrl( + personalInstallation.installationId, + personalInstallation.installationUrl, + ) + : null, + personalRepositorySelection: + personalInstallation?.repositorySelection ?? null, + orgs, + tokenExpired: true, + }; + return NextResponse.json(response); + } + try { // Fetch orgs and user profile in parallel const [orgsResponse, userResponse] = await Promise.all([ @@ -67,14 +141,31 @@ export async function GET() { }), ]); - if (!orgsResponse.ok || !userResponse.ok) { + if (!userResponse.ok || !orgsResponse.ok) { + const [userBody, orgsBody] = await Promise.all([ + userResponse.ok + ? Promise.resolve("OK") + : userResponse.text().catch(() => "unreadable"), + orgsResponse.ok + ? Promise.resolve("OK") + : orgsResponse.text().catch(() => "unreadable"), + ]); + console.error("GitHub API error in install-status:", { + userId: session.user.id, + userStatus: userResponse.status, + userBody: userResponse.ok ? "(ok)" : userBody, + orgsStatus: orgsResponse.status, + orgsBody: orgsResponse.ok ? "(ok)" : orgsBody, + tokenLength: token.length, + tokenPrefix: token.substring(0, 8) + "...", + }); return NextResponse.json( { error: "Failed to fetch GitHub data" }, { status: 502 }, ); } - const [orgs, user] = (await Promise.all([ + const [githubOrgs, user] = (await Promise.all([ orgsResponse.json(), userResponse.json(), ])) as [GitHubOrg[], GitHubUser]; @@ -85,36 +176,23 @@ export async function GET() { installations.map((i) => [i.accountLogin.toLowerCase(), i]), ); - // Build status for the personal account + // Personal account install status const personalInstallation = installationsByLogin.get( user.login.toLowerCase(), ); - const results: OrgInstallStatus[] = [ - { - githubId: user.id, - login: user.login, - avatarUrl: user.avatar_url, - type: "User", - installStatus: personalInstallation ? "installed" : "not_installed", - installationId: personalInstallation?.installationId ?? null, - installationUrl: personalInstallation - ? getInstallationManageUrl( - personalInstallation.installationId, - personalInstallation.installationUrl, - ) - : null, - repositorySelection: personalInstallation?.repositorySelection ?? null, - }, - ]; - // Build status for each org - for (const org of orgs) { - const installation = installationsByLogin.get(org.login.toLowerCase()); - results.push({ + // Build org list: merge GitHub orgs + DB installations + const seenLogins = new Set(); + const orgs: OrgInstallStatus[] = []; + + for (const org of githubOrgs) { + const lowerLogin = org.login.toLowerCase(); + seenLogins.add(lowerLogin); + const installation = installationsByLogin.get(lowerLogin); + orgs.push({ githubId: org.id, login: org.login, avatarUrl: org.avatar_url, - type: "Organization", installStatus: installation ? "installed" : "not_installed", installationId: installation?.installationId ?? null, installationUrl: installation @@ -127,7 +205,49 @@ export async function GET() { }); } - return NextResponse.json(results); + // Add any installed orgs not in the GitHub orgs list + for (const installation of installations) { + const lowerLogin = installation.accountLogin.toLowerCase(); + if ( + lowerLogin === user.login.toLowerCase() || + seenLogins.has(lowerLogin) + ) { + continue; + } + orgs.push({ + githubId: 0, + login: installation.accountLogin, + avatarUrl: "", + installStatus: "installed", + installationId: installation.installationId, + installationUrl: getInstallationManageUrl( + installation.installationId, + installation.installationUrl, + ), + repositorySelection: installation.repositorySelection, + }); + } + + const response: ConnectionStatusResponse = { + user: { + githubId: user.id, + login: user.login, + avatarUrl: user.avatar_url, + }, + personalInstallStatus: personalInstallation + ? "installed" + : "not_installed", + personalInstallationUrl: personalInstallation + ? getInstallationManageUrl( + personalInstallation.installationId, + personalInstallation.installationUrl, + ) + : null, + personalRepositorySelection: + personalInstallation?.repositorySelection ?? null, + orgs, + }; + return NextResponse.json(response); } catch (error) { console.error("Failed to fetch org install status:", error); return NextResponse.json( diff --git a/apps/web/app/settings/accounts-section.tsx b/apps/web/app/settings/accounts-section.tsx index 7f0cb6af2..6b013fd0f 100644 --- a/apps/web/app/settings/accounts-section.tsx +++ b/apps/web/app/settings/accounts-section.tsx @@ -2,20 +2,20 @@ import { AlertCircle, - Building2, - CheckCircle2, - Circle, + Check, + ChevronDown, ExternalLink, Loader2, RefreshCw, - User as UserIcon, + TriangleAlert, + X, } from "lucide-react"; -import Image from "next/image"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import useSWR, { useSWRConfig } from "swr"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -32,17 +32,31 @@ import { fetcher } from "@/lib/swr"; // ── Types ────────────────────────────────────────────────────────────────── +interface GitHubUserProfile { + githubId: number; + login: string; + avatarUrl: string; +} + interface OrgInstallStatus { githubId: number; login: string; avatarUrl: string; - type: "User" | "Organization"; installStatus: "installed" | "not_installed"; installationId: number | null; installationUrl: string | null; repositorySelection: "all" | "selected" | null; } +interface ConnectionStatusResponse { + user: GitHubUserProfile; + personalInstallStatus: "installed" | "not_installed"; + personalInstallationUrl: string | null; + personalRepositorySelection: "all" | "selected" | null; + orgs: OrgInstallStatus[]; + tokenExpired?: boolean; +} + // ── Icons ────────────────────────────────────────────────────────────────── function GitHubIcon({ className }: { className?: string }) { @@ -60,15 +74,6 @@ function GitHubIcon({ className }: { className?: string }) { // ── Navigation helpers ───────────────────────────────────────────────────── -function startGitHubInstallForOrg(githubId: number) { - const params = new URLSearchParams({ - next: "/settings/connections", - target_id: String(githubId), - }); - - window.location.href = `/api/github/app/install?${params.toString()}`; -} - function startGitHubInstallFromSettings() { const params = new URLSearchParams({ next: "/settings/connections", @@ -87,7 +92,6 @@ function useGitHubReturnToast() { if (!githubParam) return; - // Clean up URL params without navigation const url = new URL(window.location.href); url.searchParams.delete("github"); url.searchParams.delete("missing_installation_id"); @@ -148,7 +152,6 @@ function useGitHubReturnToast() { export function AccountsSectionSkeleton() { return (
- {/* GitHub section skeleton */}
@@ -157,124 +160,104 @@ export function AccountsSectionSkeleton() {
-
- {[1, 2].map((i) => ( -
-
- -
- - -
+
+
+
+ +
+ +
-
- ))} + +
); } +// ── Install status badge ─────────────────────────────────────────────────── + +function InstallBadge({ + status, + repositorySelection, +}: { + status: "installed" | "not_installed"; + repositorySelection: "all" | "selected" | null; +}) { + if (status === "installed" && repositorySelection === "all") { + return ( + + + All Repositories + + ); + } + if (status === "installed") { + return ( + + + Select Repositories + + ); + } + return ( + + + Not Installed + + ); +} + // ── Org row ──────────────────────────────────────────────────────────────── function OrgRow({ org }: { org: OrgInstallStatus }) { const isInstalled = org.installStatus === "installed"; - const isOrg = org.type === "Organization"; + // Use login-based GitHub avatar which always works, even for DB-only entries + const avatarSrc = + org.avatarUrl || + `https://avatars.githubusercontent.com/${org.login}?s=40&v=4`; return ( -
-
- {/* Avatar */} - {org.avatarUrl ? ( - {org.login} - ) : isOrg ? ( - - ) : ( - - )} - - {/* Info */} -
-
-

{org.login}

- {isOrg && ( - - org - - )} -
-

- {isInstalled ? ( - - - Installed - - ) : ( - - - Not installed - - )} -

-
+
+
+ + + + {org.login.charAt(0).toUpperCase()} + + + {org.login}
- {/* Right side: repo selection + action */} -
- {isInstalled && ( - - {org.repositorySelection === "all" - ? "all repositories" - : "selected repositories"} - - )} - {isInstalled ? ( - org.installationUrl ? ( - - ) : null - ) : ( +
+ + {isInstalled && org.installationUrl ? ( + ) : !isInstalled ? ( + - )} -
-
- ); -} - -// ── Request access guidance ──────────────────────────────────────────────── - -function RequestAccessGuidance() { - return ( -
- -
-

Missing an organization?

-

- If an organization is not listed, you may not have membership, or the - org restricts third-party access. Ask an org owner to install the - GitHub App, or request access from your organization's settings - page on GitHub. -

+ ) : null}
); @@ -289,26 +272,28 @@ export function AccountsSection() { useGitHubReturnToast(); - // Fetch org install status only when a GitHub account is linked const { - data: orgs, - isLoading: orgsLoading, - mutate: mutateOrgs, - } = useSWR( + data: connectionData, + error: connectionError, + isLoading: connectionLoading, + mutate: mutateConnection, + } = useSWR( hasGitHubAccount ? "/api/github/orgs/install-status" : null, fetcher, ); + const tokenExpired = connectionData?.tokenExpired ?? false; + const [isRefreshing, setIsRefreshing] = useState(false); const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { - await mutateOrgs(); + await mutateConnection(); } finally { setIsRefreshing(false); } - }, [mutateOrgs]); + }, [mutateConnection]); async function handleUnlink() { setUnlinking(true); @@ -316,6 +301,7 @@ export function AccountsSection() { const res = await fetch("/api/auth/github/unlink", { method: "POST" }); if (res.ok) { await mutate("/api/auth/info"); + await mutateConnection(); toast.success("GitHub disconnected"); } } catch (error) { @@ -332,84 +318,188 @@ export function AccountsSection() { return (
- {/* ── GitHub connection ── */} - - - {/* ── Future: MCP connections would go here ── */} - {/* */} +
+ {/* Header */} +
+
+ + GitHub +
+ {hasGitHub && ( + + )} +
+ + {/* Body */} +
+ {!hasGitHub ? ( + + ) : connectionLoading && !connectionData ? ( + + ) : connectionError && !connectionData ? ( + + ) : connectionData ? ( + + ) : ( + + )} +
+
); } -// ── GitHub connection block ──────────────────────────────────────────────── +// ── Not connected ────────────────────────────────────────────────────────── + +function NotConnectedState() { + return ( +
+

+ Connect your GitHub account to access repositories. +

+ +
+ ); +} -function GitHubConnection({ - hasGitHub, - orgs, - orgsLoading, - isRefreshing, +// ── Error state ──────────────────────────────────────────────────────────── + +function ConnectionErrorState({ onRetry }: { onRetry: () => void }) { + return ( +
+
+ + Failed to load GitHub connection info. +
+ +
+ ); +} + +// ── Loading skeleton for connection data ─────────────────────────────────── + +function ConnectionLoadingSkeleton() { + return ( +
+
+ +
+ + +
+
+ +
+ ); +} + +// ── Connected state ──────────────────────────────────────────────────────── + +function ConnectedState({ + data, + tokenExpired, unlinking, - onRefresh, onUnlink, }: { - hasGitHub: boolean; - orgs: OrgInstallStatus[] | null; - orgsLoading: boolean; - isRefreshing: boolean; + data: ConnectionStatusResponse; + tokenExpired: boolean; unlinking: boolean; - onRefresh: () => void; onUnlink: () => void; }) { const [disconnectOpen, setDisconnectOpen] = useState(false); - const installedCount = - orgs?.filter((o) => o.installStatus === "installed").length ?? 0; + const [orgsExpanded, setOrgsExpanded] = useState(false); + const installedOrgCount = data.orgs.filter( + (o) => o.installStatus === "installed", + ).length; return ( -
- {/* Header: GitHub branding + actions */} -
-
- -
- GitHub - {hasGitHub && ( - - · {installedCount}{" "} - {installedCount === 1 ? "account" : "accounts"} configured - + <> + {/* ── User identity ── */} +
+
+ + + + {data.user.login.charAt(0).toUpperCase()} + + +
+

{data.user.login}

+ {tokenExpired && ( +

+ + + Session expired + +

)}
-
- {hasGitHub && ( + +
+ {tokenExpired ? ( + + ) : ( <> - - + )} + {data.personalInstallationUrl && ( + + )} )} - {!hasGitHub && ( - - )}
+ {/* ── Organizations accordion ── */} + {data.orgs.length > 0 && !tokenExpired && ( +
+ + + {orgsExpanded && ( +
+ {data.orgs.map((org) => ( + + ))} + + {/* Add organization */} +
+ +
+ + {/* Guidance */} +
+
+ +
+

+ Missing an organization? +

+

+ If an organization is not listed, you may not have + membership, or the org restricts third-party access. Ask + an org owner to install the GitHub App, or request access + from your organization's settings page on GitHub. +

+
+
+
+
+ )} +
+ )} + {/* Disconnect confirmation dialog */} @@ -467,49 +601,6 @@ function GitHubConnection({ - - {/* Body */} -
- {!hasGitHub ? ( -

- Connect GitHub to access private repositories and enable - installations for your accounts and organizations. -

- ) : orgsLoading && !orgs ? ( -
- {[1, 2].map((i) => ( -
-
- -
- - -
-
- -
- ))} -
- ) : orgs && orgs.length > 0 ? ( -
- {orgs.map((org) => ( - - ))} - -
- ) : ( -
-

- No accounts found. Install the GitHub App to an account or - organization. -

- -
- )} -
-
+ ); } diff --git a/apps/web/hooks/use-session.ts b/apps/web/hooks/use-session.ts index 0bab76f8e..8afeb2dfd 100644 --- a/apps/web/hooks/use-session.ts +++ b/apps/web/hooks/use-session.ts @@ -10,7 +10,6 @@ export function useSession() { fetcher, { revalidateOnFocus: true, - fallbackData: { user: undefined }, }, );