diff --git a/PLAN.md b/PLAN.md index 4cdd960b5..abc02c9b5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,3 +1,35 @@ +Summary: Detect invalid GitHub connections from live GitHub data instead of trusting cached installation rows, then show a global reconnect prompt that sends the user through a non-destructive re-auth flow before they keep using the app. + +Context: +- GitHub installation state is currently treated as DB-backed cached data. `apps/web/app/api/auth/info/route.ts` only reports whether an account row or installation rows exist; it never checks whether the GitHub token or installations are still valid. +- Installation sync only happens in `apps/web/app/api/github/app/install/route.ts`, `apps/web/app/api/github/app/callback/route.ts`, and via webhook updates in `apps/web/app/api/github/webhook/route.ts`. Normal app loads do not refresh installation truth from GitHub. +- The connectors UI in `apps/web/app/settings/accounts-section.tsx` fetches `/api/github/orgs/install-status`, which depends on `getUserGitHubToken()` plus DB installation rows. If the GitHub token is invalid or GitHub returns an auth error, the component currently falls through to the empty state and looks like “zero installations” instead of surfacing “reconnect GitHub”. +- Repo-selection surfaces (`apps/web/components/repo-selector-compact.tsx`, `apps/web/components/repo-selector.tsx`, `apps/web/components/create-repo-dialog.tsx`) also trust `/api/github/installations`, which is DB-only and does not distinguish “never installed” from “previously connected but now invalidated”. +- There is already reconnect intent in the codebase: `apps/web/app/api/auth/github/unlink/route.ts` sets a `github_reconnect` cookie, and `apps/web/app/api/github/app/callback/route.ts` clears it, but `apps/web/app/api/github/app/install/route.ts` never reads it. So reconnect support is partially sketched but not actually wired. + +System Impact: +- Source of truth for “is GitHub usable right now?” should shift from cached DB presence to a lightweight GitHub health check using the current user token plus a fresh installation sync. +- The DB remains the local cache for installation lists, but reconnect gating should be derived from live validation, not just row existence. +- A global reconnect state becomes available to all authenticated screens, so the app can block or interrupt flows before users hit repo picker, sandbox creation, or settings confusion. +- The reconnect action should be non-destructive: re-run OAuth/install flow first, then refresh cached installations. Do not require manual disconnect before reconnect. + +Approach: +- Add a dedicated GitHub connection-health endpoint that, for authenticated users with a linked GitHub account, validates the user token, attempts a fresh installation sync, and returns one of: connected, disconnected, or reconnect_required. +- Treat these cases as reconnect_required: user token missing/refresh failed, GitHub auth failure during the health check, or installations dropping from previously-present to zero after a live sync. +- Add a dedicated reconnect entrypoint that sends the user back through the existing GitHub install/auth flow in reconnect mode without forcing them to manually disconnect first. +- Mount a global authenticated reconnect gate near the app root so the prompt appears before users start a session or navigate deep into flows. +- Tighten local surfaces so settings/repo selection show explicit reconnect messaging instead of ambiguous empty states when the health check has already determined GitHub is invalid. + +Changes: +- `apps/web/app/api/github/connection-status/route.ts` - new endpoint that validates the GitHub account, performs a guarded live installation sync, and returns structured status/reason/action URL data. +- `apps/web/lib/github/installations-sync.ts` - optionally add small error classification helpers so callers can distinguish auth failures from transient GitHub/API failures. +- `apps/web/app/api/github/app/install/route.ts` - honor reconnect mode instead of blindly using the existing linked-account path; route reconnects through OAuth when needed. +- `apps/web/app/api/auth/github/reconnect/route.ts` (new) or equivalent install-route support - provide a stable non-destructive reconnect URL that preserves `next`. +- `apps/web/app/providers.tsx` - mount a global reconnect checker/gate for authenticated users, likely via a small child component under the existing SWR provider. +- `apps/web/components/github-reconnect-dialog.tsx` (new) - blocking or near-blocking reconnect prompt with primary CTA back into the reconnect flow. +- `apps/web/app/settings/accounts-section.tsx` - replace the current misleading empty/error fallthrough with explicit reconnect-aware states. +- `apps/web/components/repo-selector-compact.tsx`, `apps/web/components/repo-selector.tsx`, `apps/web/components/create-repo-dialog.tsx` - consume the same reconnect status so repo-related entry points show the right CTA instead of generic “no installations”. +- Tests for the new connection-status route and reconnect-mode install flow, plus focused UI tests where practical. Summary: Ship a shareable public usage profile at `/[username]` backed by existing usage data, gated by an opt-in setting, with `?date=` filtering for both presets and explicit ranges, plus a dynamic OG image generated from the same derived stats. Context: @@ -41,10 +73,15 @@ Changes: Verification: - Run `bun run --cwd apps/web db:generate` after the schema change. - Run `bun run ci`. +- With a healthy GitHub connection, confirm the global gate does not appear and installation/repo pickers behave as before. +- Simulate an invalid GitHub token or revoked/reduced installation state and confirm the app shows the reconnect prompt before normal use. +- Confirm the reconnect CTA returns the user to their original page and repopulates installations without requiring manual disconnect. +- Confirm settings/connections now shows a reconnect-specific state instead of a misleading zero-installations empty state. +- Confirm repo picker, create-repo flow, and sandbox/session entry points all surface the same reconnect path if reached while invalid. - Manually verify: - opt-in off => `/[username]` 404s - opt-in on => `/[username]` renders public stats - `?date=30d` and `?date=2026-01-01..2026-01-31` both filter correctly - invalid `?date=` returns a safe fallback or 400-style handling on the public surface, depending on final implementation choice - social metadata points at the generated image and the image reflects the selected date filter - - existing `/[username]/[repo]` onboarding still works unchanged \ No newline at end of file + - existing `/[username]/[repo]` onboarding still works unchanged diff --git a/apps/web/app/api/auth/github/reconnect/route.test.ts b/apps/web/app/api/auth/github/reconnect/route.test.ts new file mode 100644 index 000000000..2df4d6960 --- /dev/null +++ b/apps/web/app/api/auth/github/reconnect/route.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { NextRequest } from "next/server"; + +const routeModulePromise = import("./route"); + +const originalNodeEnv = process.env.NODE_ENV; + +function createRequest(url: string): NextRequest { + return { + url, + nextUrl: new URL(url), + } as NextRequest; +} + +describe("GET /api/auth/github/reconnect", () => { + beforeEach(() => { + Object.assign(process.env, { NODE_ENV: "test" }); + }); + + afterEach(() => { + Object.assign(process.env, { NODE_ENV: originalNodeEnv }); + }); + + test("sets reconnect mode and redirects into the install flow", async () => { + const { GET } = await routeModulePromise; + + const response = await GET( + createRequest( + "http://localhost/api/auth/github/reconnect?next=/sessions", + ), + ); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "http://localhost/api/github/app/install?next=%2Fsessions", + ); + + const setCookie = response.headers.get("set-cookie") ?? ""; + expect(setCookie).toContain("github_reconnect=1"); + }); +}); diff --git a/apps/web/app/api/auth/github/reconnect/route.ts b/apps/web/app/api/auth/github/reconnect/route.ts new file mode 100644 index 000000000..6c17a621a --- /dev/null +++ b/apps/web/app/api/auth/github/reconnect/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; + +function sanitizeRedirectTo(rawRedirectTo: string | null): string { + if (!rawRedirectTo) { + return "/settings/connections"; + } + + if (!rawRedirectTo.startsWith("/") || rawRedirectTo.startsWith("//")) { + return "/settings/connections"; + } + + return rawRedirectTo; +} + +export async function GET(req: NextRequest): Promise { + const redirectTo = sanitizeRedirectTo(req.nextUrl.searchParams.get("next")); + const installUrl = new URL("/api/github/app/install", req.url); + installUrl.searchParams.set("next", redirectTo); + + const response = NextResponse.redirect(installUrl); + response.cookies.set("github_reconnect", "1", { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 60, + sameSite: "lax", + }); + + return response; +} diff --git a/apps/web/app/api/github/app/install/route.test.ts b/apps/web/app/api/github/app/install/route.test.ts new file mode 100644 index 000000000..2ba02d9c2 --- /dev/null +++ b/apps/web/app/api/github/app/install/route.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { NextRequest } from "next/server"; + +let authSession: { user: { id: string } } | null; +let githubAccount: { externalUserId: string } | null; +let installations: Array<{ installationId: number }>; + +mock.module("arctic", () => ({ + generateState: () => "state-123", +})); + +mock.module("@/lib/session/get-server-session", () => ({ + getServerSession: async () => authSession, +})); + +mock.module("@/lib/db/accounts", () => ({ + getGitHubAccount: async () => githubAccount, +})); + +mock.module("@/lib/db/installations", () => ({ + getInstallationsByUserId: async () => installations, +})); + +mock.module("@/lib/crypto", () => ({ + decrypt: () => "ghu_saved", +})); + +mock.module("@/lib/github/installations-sync", () => ({ + syncUserInstallations: async () => installations.length, +})); + +const routeModulePromise = import("./route"); + +const originalEnv = { + NEXT_PUBLIC_GITHUB_APP_SLUG: process.env.NEXT_PUBLIC_GITHUB_APP_SLUG, + NEXT_PUBLIC_GITHUB_CLIENT_ID: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID, + NODE_ENV: process.env.NODE_ENV, +}; + +function createRequest( + url: string, + cookieValues: Record = {}, +): NextRequest { + const nextUrl = new URL(url); + + return { + url, + nextUrl, + cookies: { + get: (name: string) => { + const value = cookieValues[name]; + return value ? { value } : undefined; + }, + }, + } as NextRequest; +} + +describe("GET /api/github/app/install", () => { + beforeEach(() => { + authSession = { user: { id: "user-1" } }; + githubAccount = { externalUserId: "123" }; + installations = [{ installationId: 1 }]; + + Object.assign(process.env, { + NEXT_PUBLIC_GITHUB_APP_SLUG: "open-harness", + NEXT_PUBLIC_GITHUB_CLIENT_ID: "client-id", + NODE_ENV: "test", + }); + }); + + afterEach(() => { + Object.assign(process.env, { + NEXT_PUBLIC_GITHUB_APP_SLUG: originalEnv.NEXT_PUBLIC_GITHUB_APP_SLUG, + NEXT_PUBLIC_GITHUB_CLIENT_ID: originalEnv.NEXT_PUBLIC_GITHUB_CLIENT_ID, + NODE_ENV: originalEnv.NODE_ENV, + }); + }); + + test("forces OAuth when reconnect mode is active", async () => { + const { GET } = await routeModulePromise; + + const response = await GET( + createRequest("http://localhost/api/github/app/install?next=/sessions", { + github_reconnect: "1", + }), + ); + + expect(response.status).toBe(307); + + const location = response.headers.get("location"); + expect(location).toBeTruthy(); + + const redirectUrl = new URL(location as string); + expect(redirectUrl.origin).toBe("https://github.com"); + expect(redirectUrl.pathname).toBe("/login/oauth/authorize"); + expect(redirectUrl.searchParams.get("client_id")).toBe("client-id"); + expect(redirectUrl.searchParams.get("state")).toBe("state-123"); + expect(redirectUrl.searchParams.get("redirect_uri")).toBe( + "http://localhost/api/github/app/callback", + ); + + const setCookie = response.headers.get("set-cookie") ?? ""; + expect(setCookie).toContain("github_app_install_redirect_to=%2Fsessions"); + expect(setCookie).toContain("github_app_install_state=state-123"); + }); +}); diff --git a/apps/web/app/api/github/app/install/route.ts b/apps/web/app/api/github/app/install/route.ts index 192751409..7ed814ba5 100644 --- a/apps/web/app/api/github/app/install/route.ts +++ b/apps/web/app/api/github/app/install/route.ts @@ -26,6 +26,13 @@ const COOKIE_OPTIONS = { sameSite: "lax" as const, }; +function shouldForceReconnect(req: NextRequest): boolean { + return ( + req.nextUrl.searchParams.get("reconnect") === "1" || + req.cookies.get("github_reconnect")?.value === "1" + ); +} + /** * Create a redirect response with install cookies set directly on it. * Using NextResponse.redirect() + response.cookies.set() ensures cookies @@ -82,6 +89,24 @@ export async function GET(req: NextRequest): Promise { return redirectWithInstallCookies(installUrl, redirectTo, state); } + if (shouldForceReconnect(req)) { + const clientId = process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID; + if (clientId) { + const authorizeUrl = new URL("https://github.com/login/oauth/authorize"); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("state", state); + const callbackUrl = new URL("/api/github/app/callback", req.url); + authorizeUrl.searchParams.set("redirect_uri", callbackUrl.toString()); + return redirectWithInstallCookies(authorizeUrl, redirectTo, state); + } + + const selectTargetUrl = new URL( + `https://github.com/apps/${appSlug}/installations/select_target`, + ); + selectTargetUrl.searchParams.set("state", state); + return redirectWithInstallCookies(selectTargetUrl, redirectTo, state); + } + const ghAccount = await getGitHubAccount(session.user.id); let installations = ghAccount ? await getInstallationsByUserId(session.user.id) diff --git a/apps/web/app/api/github/connection-status/route.test.ts b/apps/web/app/api/github/connection-status/route.test.ts new file mode 100644 index 000000000..518a6eb60 --- /dev/null +++ b/apps/web/app/api/github/connection-status/route.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +mock.module("server-only", () => ({})); + +type AuthSession = { + user: { + id: string; + }; +} | null; + +let authSession: AuthSession; +let githubAccount: { username: string } | null; +let installations: Array<{ installationId: number }>; +let userToken: string | null; +let syncedInstallationsCount = 0; +let syncError: Error | null; +let syncErrorIsAuth = false; + +mock.module("@/lib/session/get-server-session", () => ({ + getServerSession: async () => authSession, +})); + +mock.module("@/lib/db/accounts", () => ({ + getGitHubAccount: async () => githubAccount, +})); + +mock.module("@/lib/db/installations", () => ({ + getInstallationsByUserId: async () => installations, +})); + +mock.module("@/lib/github/user-token", () => ({ + getUserGitHubToken: async () => userToken, +})); + +mock.module("@/lib/github/installations-sync", () => ({ + syncUserInstallations: async () => { + if (syncError) { + throw syncError; + } + + return syncedInstallationsCount; + }, + isGitHubInstallationsAuthError: () => syncErrorIsAuth, +})); + +const routeModulePromise = import("./route"); + +describe("GET /api/github/connection-status", () => { + beforeEach(() => { + authSession = { user: { id: "user-1" } }; + githubAccount = { username: "octocat" }; + installations = [{ installationId: 1 }]; + userToken = "ghu_user"; + syncedInstallationsCount = 1; + syncError = null; + syncErrorIsAuth = false; + }); + + test("returns 401 when unauthenticated", async () => { + authSession = null; + const { GET } = await routeModulePromise; + + const response = await GET(); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Not authenticated" }); + }); + + test("returns not_connected when no GitHub account is linked", async () => { + githubAccount = null; + installations = []; + const { GET } = await routeModulePromise; + + const response = await GET(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: "not_connected", + reason: null, + hasInstallations: false, + syncedInstallationsCount: 0, + }); + }); + + test("requires reconnect when no usable token is available", async () => { + userToken = null; + const { GET } = await routeModulePromise; + + const response = await GET(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: "reconnect_required", + reason: "token_unavailable", + hasInstallations: true, + syncedInstallationsCount: null, + }); + }); + + test("requires reconnect when live sync drops cached installations to zero", async () => { + syncedInstallationsCount = 0; + const { GET } = await routeModulePromise; + + const response = await GET(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: "reconnect_required", + reason: "installations_missing", + hasInstallations: false, + syncedInstallationsCount: 0, + }); + }); + + test("stays connected when sync succeeds with installations", async () => { + syncedInstallationsCount = 2; + const { GET } = await routeModulePromise; + + const response = await GET(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: "connected", + reason: null, + hasInstallations: true, + syncedInstallationsCount: 2, + }); + }); + + test("stays connected when the account has no installations yet", async () => { + installations = []; + syncedInstallationsCount = 0; + const { GET } = await routeModulePromise; + + const response = await GET(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: "connected", + reason: null, + hasInstallations: false, + syncedInstallationsCount: 0, + }); + }); + + test("requires reconnect when GitHub rejects installation sync auth", async () => { + syncError = new Error("GitHub auth failed"); + syncErrorIsAuth = true; + const { GET } = await routeModulePromise; + + const response = await GET(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: "reconnect_required", + reason: "sync_auth_failed", + hasInstallations: true, + syncedInstallationsCount: null, + }); + }); +}); diff --git a/apps/web/app/api/github/connection-status/route.ts b/apps/web/app/api/github/connection-status/route.ts new file mode 100644 index 000000000..c741ab904 --- /dev/null +++ b/apps/web/app/api/github/connection-status/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { getGitHubAccount } from "@/lib/db/accounts"; +import { getInstallationsByUserId } from "@/lib/db/installations"; +import type { GitHubConnectionStatusResponse } from "@/lib/github/connection-status"; +import { + isGitHubInstallationsAuthError, + syncUserInstallations, +} from "@/lib/github/installations-sync"; +import { getUserGitHubToken } from "@/lib/github/user-token"; +import { getServerSession } from "@/lib/session/get-server-session"; + +export async function GET() { + const session = await getServerSession(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const [ghAccount, installations] = await Promise.all([ + getGitHubAccount(session.user.id), + getInstallationsByUserId(session.user.id), + ]); + + if (!ghAccount) { + return NextResponse.json({ + status: "not_connected", + reason: null, + hasInstallations: installations.length > 0, + syncedInstallationsCount: installations.length, + } satisfies GitHubConnectionStatusResponse); + } + + const token = await getUserGitHubToken(session.user.id); + if (!token) { + return NextResponse.json({ + status: "reconnect_required", + reason: "token_unavailable", + hasInstallations: installations.length > 0, + syncedInstallationsCount: null, + } satisfies GitHubConnectionStatusResponse); + } + + try { + const syncedInstallationsCount = await syncUserInstallations( + session.user.id, + token, + ); + const reconnectRequired = + installations.length > 0 && syncedInstallationsCount === 0; + + return NextResponse.json({ + status: reconnectRequired ? "reconnect_required" : "connected", + reason: reconnectRequired ? "installations_missing" : null, + hasInstallations: syncedInstallationsCount > 0, + syncedInstallationsCount, + } satisfies GitHubConnectionStatusResponse); + } catch (error) { + if (isGitHubInstallationsAuthError(error)) { + return NextResponse.json({ + status: "reconnect_required", + reason: "sync_auth_failed", + hasInstallations: installations.length > 0, + syncedInstallationsCount: null, + } satisfies GitHubConnectionStatusResponse); + } + + console.error("Failed to validate GitHub connection status:", error); + + return NextResponse.json({ + status: "connected", + reason: null, + hasInstallations: installations.length > 0, + syncedInstallationsCount: installations.length, + } satisfies GitHubConnectionStatusResponse); + } +} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 2b569f690..93bd53094 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; import { + Suspense, createContext, useCallback, useContext, @@ -12,6 +13,7 @@ import { } from "react"; import { Toaster } from "sonner"; import { SWRConfig } from "swr"; +import { GitHubReconnectGate } from "@/components/github-reconnect-gate"; import { FetchError } from "@/lib/swr"; const THEME_STORAGE_KEY = "open-agents-theme"; @@ -129,7 +131,12 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + + + + ); diff --git a/apps/web/app/settings/accounts-section.tsx b/apps/web/app/settings/accounts-section.tsx index 6b013fd0f..bb8e32565 100644 --- a/apps/web/app/settings/accounts-section.tsx +++ b/apps/web/app/settings/accounts-section.tsx @@ -27,11 +27,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; +import { useGitHubConnectionStatus } from "@/hooks/use-github-connection-status"; import { useSession } from "@/hooks/use-session"; +import { buildGitHubReconnectUrl } from "@/lib/github/connection-status"; import { fetcher } from "@/lib/swr"; -// ── Types ────────────────────────────────────────────────────────────────── - interface GitHubUserProfile { githubId: number; login: string; @@ -57,8 +57,6 @@ interface ConnectionStatusResponse { tokenExpired?: boolean; } -// ── Icons ────────────────────────────────────────────────────────────────── - function GitHubIcon({ className }: { className?: string }) { return ( @@ -177,8 +199,6 @@ export function AccountsSectionSkeleton() { ); } -// ── Install status badge ─────────────────────────────────────────────────── - function InstallBadge({ status, repositorySelection, @@ -210,28 +230,25 @@ function InstallBadge({ ); } -// ── Org row ──────────────────────────────────────────────────────────────── - function OrgRow({ org }: { org: OrgInstallStatus }) { const isInstalled = org.installStatus === "installed"; - // 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 (
-
+
{org.login.charAt(0).toUpperCase()} - {org.login} + {org.login}
-
+
@@ -252,8 +269,8 @@ function OrgRow({ org }: { org: OrgInstallStatus }) { @@ -263,12 +280,17 @@ function OrgRow({ org }: { org: OrgInstallStatus }) { ); } -// ── Main section ─────────────────────────────────────────────────────────── - export function AccountsSection() { const { hasGitHubAccount, hasGitHub, loading } = useSession(); const { mutate } = useSWRConfig(); const [unlinking, setUnlinking] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const { + reconnectRequired, + reason, + isLoading: connectionStatusLoading, + refresh: refreshConnectionStatus, + } = useGitHubConnectionStatus({ enabled: hasGitHub }); useGitHubReturnToast(); @@ -284,16 +306,14 @@ export function AccountsSection() { const tokenExpired = connectionData?.tokenExpired ?? false; - const [isRefreshing, setIsRefreshing] = useState(false); - const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { - await mutateConnection(); + await Promise.all([mutateConnection(), refreshConnectionStatus()]); } finally { setIsRefreshing(false); } - }, [mutateConnection]); + }, [mutateConnection, refreshConnectionStatus]); async function handleUnlink() { setUnlinking(true); @@ -301,7 +321,7 @@ export function AccountsSection() { const res = await fetch("/api/auth/github/unlink", { method: "POST" }); if (res.ok) { await mutate("/api/auth/info"); - await mutateConnection(); + await Promise.all([mutateConnection(), refreshConnectionStatus()]); toast.success("GitHub disconnected"); } } catch (error) { @@ -319,7 +339,6 @@ export function AccountsSection() { return (
- {/* Header */}
@@ -330,7 +349,9 @@ export function AccountsSection() { variant="ghost" size="sm" onClick={handleRefresh} - disabled={isRefreshing || connectionLoading} + disabled={ + isRefreshing || connectionLoading || connectionStatusLoading + } className="h-7 w-7 p-0" > - {/* Body */} -
+
{!hasGitHub ? ( ) : connectionLoading && !connectionData ? ( + ) : reconnectRequired && !connectionData ? ( + ) : connectionError && !connectionData ? ( ) : connectionData ? ( @@ -384,8 +409,6 @@ function NotConnectedState() { ); } -// ── Error state ──────────────────────────────────────────────────────────── - function ConnectionErrorState({ onRetry }: { onRetry: () => void }) { return (
@@ -405,8 +428,6 @@ function ConnectionErrorState({ onRetry }: { onRetry: () => void }) { ); } -// ── Loading skeleton for connection data ─────────────────────────────────── - function ConnectionLoadingSkeleton() { return (
@@ -422,30 +443,56 @@ function ConnectionLoadingSkeleton() { ); } -// ── Connected state ──────────────────────────────────────────────────────── +function ReconnectRequiredState({ + reconnectReason, + tokenExpired, +}: { + reconnectReason: string | null; + tokenExpired: boolean; +}) { + return ( +
+

+ Reconnect GitHub to continue +

+

+ {getReconnectDescription(reconnectReason, tokenExpired)} +

+
+ +
+
+ ); +} function ConnectedState({ data, + reconnectRequired, + reconnectReason, tokenExpired, unlinking, onUnlink, }: { data: ConnectionStatusResponse; + reconnectRequired: boolean; + reconnectReason: string | null; tokenExpired: boolean; unlinking: boolean; onUnlink: () => void; }) { const [disconnectOpen, setDisconnectOpen] = useState(false); const [orgsExpanded, setOrgsExpanded] = useState(false); + const requiresReconnect = reconnectRequired || tokenExpired; const installedOrgCount = data.orgs.filter( - (o) => o.installStatus === "installed", + (org) => org.installStatus === "installed", ).length; return ( <> - {/* ── User identity ── */}
-
+
@@ -453,37 +500,50 @@ function ConnectedState({
-

{data.user.login}

- {tokenExpired && ( +

{data.user.login}

+ {requiresReconnect ? (

- Session expired + Reconnect required

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

Missing an organization? @@ -571,11 +633,10 @@ function ConnectedState({

- )} + ) : null}
- )} + ) : null} - {/* Disconnect confirmation dialog */} diff --git a/apps/web/components/create-repo-dialog.tsx b/apps/web/components/create-repo-dialog.tsx index 8319f7bef..367b6039f 100644 --- a/apps/web/components/create-repo-dialog.tsx +++ b/apps/web/components/create-repo-dialog.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import useSWR from "swr"; -import { ExternalLink, Check, Loader2, FolderGit2 } from "lucide-react"; +import { Check, ExternalLink, FolderGit2, Loader2 } from "lucide-react"; import { Dialog, DialogContent, @@ -11,6 +11,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useGitHubConnectionStatus } from "@/hooks/use-github-connection-status"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -24,6 +25,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import type { Session } from "@/lib/db/schema"; +import { buildGitHubReconnectUrl } from "@/lib/github/connection-status"; interface CreateRepoDialogProps { open: boolean; @@ -59,6 +61,10 @@ async function fetchInstallations(): Promise { return Array.isArray(data) ? data : []; } +function getCurrentPathWithSearch(): string { + return `${window.location.pathname}${window.location.search}`; +} + function slugify(text: string): string { return text .toLowerCase() @@ -83,11 +89,19 @@ export function CreateRepoDialog({ const [result, setResult] = useState(null); const [error, setError] = useState(null); const [selectedOwner, setSelectedOwner] = useState(""); + const { reconnectRequired } = useGitHubConnectionStatus({ enabled: open }); + + const handleReconnect = () => { + window.location.href = buildGitHubReconnectUrl(getCurrentPathWithSearch()); + }; // Use SWR for installations (shares cache with RepoSelectorCompact) const { data: installations = [], isLoading: loadingInstallations } = useSWR< Installation[] - >(open ? "github-installations" : null, fetchInstallations); + >( + open && !reconnectRequired ? "github-installations" : null, + fetchInstallations, + ); // Reset form state when dialog opens useEffect(() => { @@ -124,6 +138,11 @@ export function CreateRepoDialog({ return; } + if (reconnectRequired) { + setError("Reconnect GitHub before creating a repository."); + return; + } + if (!selectedOwner) { setError( "Select an account to create the repository under. Install the GitHub App on an account first.", @@ -223,7 +242,22 @@ export function CreateRepoDialog({ {/* Owner / Account Picker */}
- {loadingInstallations ? ( + {reconnectRequired ? ( +
+

+ Your saved GitHub connection is no longer valid. Reconnect + before creating a repository. +

+ +
+ ) : loadingInstallations ? (
Loading accounts... @@ -327,6 +361,7 @@ export function CreateRepoDialog({ onClick={handleCreate} disabled={ isCreating || + reconnectRequired || !repoName.trim() || !hasSandbox || !selectedOwner diff --git a/apps/web/components/github-reconnect-dialog.tsx b/apps/web/components/github-reconnect-dialog.tsx new file mode 100644 index 000000000..d7a84065c --- /dev/null +++ b/apps/web/components/github-reconnect-dialog.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import type { GitHubConnectionReason } from "@/lib/github/connection-status"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +function getReconnectDescription( + reason: GitHubConnectionReason | null, +): string { + switch (reason) { + case "installations_missing": + return "GitHub no longer reports your app installation. This usually happens after app permission changes or an installation being invalidated."; + case "sync_auth_failed": + return "GitHub rejected the saved connection while we refreshed your installation access."; + case "token_unavailable": + return "Your saved GitHub token is no longer usable."; + default: + return "Your GitHub connection needs to be refreshed before you continue."; + } +} + +export function GitHubReconnectDialog({ + open, + reconnectUrl, + reason, +}: { + open: boolean; + reconnectUrl: string; + reason: GitHubConnectionReason | null; +}) { + return ( + + + + Reconnect GitHub + + {getReconnectDescription(reason)} Reconnect now to restore + repository access and keep using the app. + + + + + + + + ); +} diff --git a/apps/web/components/github-reconnect-gate.tsx b/apps/web/components/github-reconnect-gate.tsx new file mode 100644 index 000000000..64759fe90 --- /dev/null +++ b/apps/web/components/github-reconnect-gate.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; +import { useMemo } from "react"; +import { useGitHubConnectionStatus } from "@/hooks/use-github-connection-status"; +import { useSession } from "@/hooks/use-session"; +import { buildGitHubReconnectUrl } from "@/lib/github/connection-status"; +import { GitHubReconnectDialog } from "./github-reconnect-dialog"; + +export function GitHubReconnectGate() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { isAuthenticated, loading } = useSession(); + const { reconnectRequired, reason, isLoading } = useGitHubConnectionStatus({ + enabled: isAuthenticated, + }); + + const reconnectUrl = useMemo(() => { + const search = searchParams.toString(); + const next = search ? `${pathname}?${search}` : pathname; + return buildGitHubReconnectUrl(next); + }, [pathname, searchParams]); + + if (loading || !isAuthenticated || isLoading || !reconnectRequired) { + return null; + } + + return ( + + ); +} diff --git a/apps/web/components/repo-selector-compact.tsx b/apps/web/components/repo-selector-compact.tsx index af2fc50f4..3a41c7daa 100644 --- a/apps/web/components/repo-selector-compact.tsx +++ b/apps/web/components/repo-selector-compact.tsx @@ -27,7 +27,9 @@ import { InstallationRepo, useInstallationRepos, } from "@/hooks/use-installation-repos"; +import { useGitHubConnectionStatus } from "@/hooks/use-github-connection-status"; import { useSession } from "@/hooks/use-session"; +import { buildGitHubReconnectUrl } from "@/lib/github/connection-status"; import { cn } from "@/lib/utils"; function GitHubIcon({ className }: { className?: string }) { @@ -117,12 +119,44 @@ function SkeletonRow() { ); } +function GitHubActionCard({ + title, + description, + buttonLabel, + onClick, +}: { + title: string; + description: string; + buttonLabel: string; + onClick: () => void; +}) { + return ( +
+ +
+

{title}

+

{description}

+
+ +
+ ); +} + export function RepoSelectorCompact({ selectedOwner, selectedRepo, onSelect, }: RepoSelectorCompactProps) { const { hasGitHub, loading: sessionLoading } = useSession(); + const { reconnectRequired } = useGitHubConnectionStatus({ + enabled: hasGitHub, + }); const [ownerOpen, setOwnerOpen] = useState(false); const [currentOwner, setCurrentOwner] = useState(selectedOwner); const [repoSearch, setRepoSearch] = useState(""); @@ -138,9 +172,16 @@ export function RepoSelectorCompact({ window.location.href = `/api/github/app/install?${params.toString()}`; }, []); + const startGitHubReconnect = useCallback(() => { + window.location.href = buildGitHubReconnectUrl(getCurrentPathWithSearch()); + }, []); + const { data: installations = [], isLoading: installationsLoading } = useSWR< Installation[] - >(hasGitHub ? "github-installations" : null, fetchInstallations); + >( + hasGitHub && !reconnectRequired ? "github-installations" : null, + fetchInstallations, + ); const currentInstallation = installations.find( (installation) => installation.accountLogin === currentOwner, @@ -229,44 +270,35 @@ export function RepoSelectorCompact({ // Not connected to GitHub if (!sessionLoading && !hasGitHub) { return ( -
- -
-

Install GitHub App

-

- Continue on GitHub to choose which repositories are available. -

-
- -
+ + ); + } + + if (reconnectRequired) { + return ( + ); } // No installations if (!installationsLoading && installations.length === 0) { return ( -
- -
-

Install GitHub App

-

- Install the GitHub App to choose which repositories are available. -

-
- -
+ ); } diff --git a/apps/web/components/repo-selector.tsx b/apps/web/components/repo-selector.tsx index e1cb626c5..b570bc5bf 100644 --- a/apps/web/components/repo-selector.tsx +++ b/apps/web/components/repo-selector.tsx @@ -24,7 +24,10 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { useGitHubConnectionStatus } from "@/hooks/use-github-connection-status"; import { useInstallationRepos } from "@/hooks/use-installation-repos"; +import { useSession } from "@/hooks/use-session"; +import { buildGitHubReconnectUrl } from "@/lib/github/connection-status"; import { cn } from "@/lib/utils"; interface Installation { @@ -54,6 +57,10 @@ export function RepoSelector({ }: { onRepoSelect: (owner: string, repo: string) => void; }) { + const { hasGitHub } = useSession(); + const { reconnectRequired } = useGitHubConnectionStatus({ + enabled: hasGitHub, + }); const [installations, setInstallations] = useState([]); const [selectedOwner, setSelectedOwner] = useState(""); const [selectedRepo, setSelectedRepo] = useState(""); @@ -72,6 +79,10 @@ export function RepoSelector({ window.location.href = `/api/github/app/install?${params.toString()}`; }, []); + const startGitHubReconnect = useCallback(() => { + window.location.href = buildGitHubReconnectUrl(getCurrentPathWithSearch()); + }, []); + const selectedInstallation = installations.find( (installation) => installation.accountLogin === selectedOwner, ); @@ -89,6 +100,14 @@ export function RepoSelector({ useEffect(() => { const loadInstallations = async () => { + if (reconnectRequired) { + setInstallations([]); + setSelectedOwner(""); + setError(null); + setOwnersLoading(false); + return; + } + setOwnersLoading(true); setError(null); try { @@ -122,7 +141,7 @@ export function RepoSelector({ }; loadInstallations(); - }, []); + }, [reconnectRequired]); const handleRefresh = useCallback(async () => { setIsRefreshing(true); @@ -159,6 +178,17 @@ export function RepoSelector({ setRepoOpen(false); }; + if (reconnectRequired) { + return ( +
+

+ Your saved GitHub connection is no longer valid. +

+ +
+ ); + } + if (error) { return (
diff --git a/apps/web/components/session-starter.tsx b/apps/web/components/session-starter.tsx index 2c0db43b4..1c2bad9d7 100644 --- a/apps/web/components/session-starter.tsx +++ b/apps/web/components/session-starter.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { useGitHubConnectionStatus } from "@/hooks/use-github-connection-status"; import { useSession } from "@/hooks/use-session"; import { useUserPreferences } from "@/hooks/use-user-preferences"; import { useVercelRepoProjects } from "@/hooks/use-vercel-repo-projects"; @@ -61,7 +62,11 @@ export function SessionStarter({ string | null | undefined >(undefined); - const { session, loading: sessionLoading } = useSession(); + const { session, loading: sessionLoading, hasGitHub } = useSession(); + const { reconnectRequired, isLoading: githubConnectionLoading } = + useGitHubConnectionStatus({ + enabled: hasGitHub, + }); const { preferences, loading: preferencesLoading } = useUserPreferences(); const defaultAutoCommitPush = preferences?.autoCommitPush ?? false; const defaultAutoCreatePr = preferences?.autoCreatePr ?? false; @@ -74,6 +79,8 @@ export function SessionStarter({ const shouldLoadVercelProjects = mode === "repo" && + !githubConnectionLoading && + !reconnectRequired && !!selectedOwner && !!selectedRepo && session?.authProvider === "vercel"; @@ -148,6 +155,7 @@ export function SessionStarter({ const controlsDisabled = isLoading || preferencesLoading; const isSubmitDisabled = controlsDisabled || + (mode === "repo" && (githubConnectionLoading || reconnectRequired)) || !isRepoSelectionComplete || isVercelLookupPending || requiresVercelChoice; @@ -155,6 +163,8 @@ export function SessionStarter({ const effectiveAutoCreatePr = autoCreatePr ?? defaultAutoCreatePr; const showVercelProjectSection = mode === "repo" && + !githubConnectionLoading && + !reconnectRequired && !!selectedOwner && !!selectedRepo && (sessionLoading || session?.authProvider === "vercel"); @@ -243,15 +253,18 @@ export function SessionStarter({ selectedRepo={selectedRepo} onSelect={handleRepoSelect} /> - {selectedOwner && selectedRepo && ( - - )} + {selectedOwner && + selectedRepo && + !githubConnectionLoading && + !reconnectRequired && ( + + )} {showVercelProjectSection && ( ( + shouldFetch ? "/api/github/connection-status" : null, + fetcherNoStore, + { + dedupingInterval: 30_000, + revalidateOnFocus: true, + }, + ); + + return { + data: data ?? null, + status: data?.status ?? (shouldFetch ? null : "not_connected"), + reason: data?.reason ?? null, + hasInstallations: data?.hasInstallations ?? false, + reconnectRequired: data?.status === "reconnect_required", + isLoading: shouldFetch && isLoading, + error: error instanceof Error ? error.message : null, + refresh: mutate, + }; +} diff --git a/apps/web/lib/github/connection-status.ts b/apps/web/lib/github/connection-status.ts new file mode 100644 index 000000000..c6d9a113d --- /dev/null +++ b/apps/web/lib/github/connection-status.ts @@ -0,0 +1,21 @@ +export type GitHubConnectionStatus = + | "not_connected" + | "connected" + | "reconnect_required"; + +export type GitHubConnectionReason = + | "token_unavailable" + | "installations_missing" + | "sync_auth_failed"; + +export interface GitHubConnectionStatusResponse { + status: GitHubConnectionStatus; + reason: GitHubConnectionReason | null; + hasInstallations: boolean; + syncedInstallationsCount: number | null; +} + +export function buildGitHubReconnectUrl(next: string): string { + const params = new URLSearchParams({ next }); + return `/api/auth/github/reconnect?${params.toString()}`; +} diff --git a/apps/web/lib/github/installations-sync.test.ts b/apps/web/lib/github/installations-sync.test.ts new file mode 100644 index 000000000..c2b3dd846 --- /dev/null +++ b/apps/web/lib/github/installations-sync.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + GitHubInstallationsSyncError, + isGitHubInstallationsAuthError, +} from "./installations-sync"; + +describe("isGitHubInstallationsAuthError", () => { + test("treats 401 responses as auth failures", () => { + expect( + isGitHubInstallationsAuthError( + new GitHubInstallationsSyncError("Unauthorized", { + status: 401, + responseText: '{"message":"Bad credentials"}', + }), + ), + ).toBe(true); + }); + + test("treats auth-specific 403 responses as auth failures", () => { + expect( + isGitHubInstallationsAuthError( + new GitHubInstallationsSyncError("Forbidden", { + status: 403, + responseText: + '{"message":"Must grant your OAuth app access to this organization."}', + }), + ), + ).toBe(true); + }); + + test("does not treat rate-limited 403 responses as auth failures", () => { + expect( + isGitHubInstallationsAuthError( + new GitHubInstallationsSyncError("Forbidden", { + status: 403, + responseText: + '{"message":"API rate limit exceeded for user ID 123."}', + }), + ), + ).toBe(false); + }); +}); diff --git a/apps/web/lib/github/installations-sync.ts b/apps/web/lib/github/installations-sync.ts index 6e0f61b67..9627e55da 100644 --- a/apps/web/lib/github/installations-sync.ts +++ b/apps/web/lib/github/installations-sync.ts @@ -18,6 +18,67 @@ const userInstallationsResponseSchema = z.object({ installations: z.array(userInstallationSchema), }); +export class GitHubInstallationsSyncError extends Error { + readonly status: number; + readonly responseText: string; + + constructor( + message: string, + options: { status: number; responseText: string }, + ) { + super(message); + this.name = "GitHubInstallationsSyncError"; + this.status = options.status; + this.responseText = options.responseText; + } +} + +const GITHUB_403_AUTH_ERROR_PATTERNS = [ + "bad credentials", + "oauth access token has expired", + "oauth token has expired", + "this token has expired", + "token is expired", + "token is invalid", + "token was revoked", + "requires authentication", + "must grant your oauth app access", +]; + +function isGitHubInstallations403AuthError(responseText: string): boolean { + const normalizedResponseText = responseText.toLowerCase(); + + return GITHUB_403_AUTH_ERROR_PATTERNS.some((pattern) => + normalizedResponseText.includes(pattern), + ); +} + +export function isGitHubInstallationsAuthError(error: unknown): boolean { + if (error instanceof GitHubInstallationsSyncError) { + if (error.status === 401) { + return true; + } + + if (error.status === 403) { + return isGitHubInstallations403AuthError(error.responseText); + } + + return false; + } + + if (!(error instanceof Error)) { + return false; + } + + const normalizedMessage = error.message.toLowerCase(); + + return ( + normalizedMessage.includes(" 401 ") || + (normalizedMessage.includes(" 403 ") && + isGitHubInstallations403AuthError(normalizedMessage)) + ); +} + function normalizeAccountType(type: string): "User" | "Organization" { return type === "Organization" ? "Organization" : "User"; } @@ -41,8 +102,12 @@ async function fetchUserInstallations(userToken: string) { if (!response.ok) { const responseText = await response.text(); - throw new Error( + throw new GitHubInstallationsSyncError( `Failed to fetch GitHub installations page ${page}: ${response.status} ${responseText}`, + { + status: response.status, + responseText, + }, ); }