From 95c23db4fc0aed53df811e2eb127c544ab3c0aa2 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:18:09 +0530 Subject: [PATCH] feat(web): debounced github repo search and connection settings Search hook calls gitConnections:searchGithubRepositories with the stored encrypted credential (token never reaches the browser). Connection settings popover shows granted scopes and drives reauthorization with the expanded scope set. --- .../projects/github-connection-settings.tsx | 194 ++++++++++++++++++ .../components/projects/provider-chips.tsx | 29 ++- apps/web/src/hooks/use-github-repo-search.ts | 99 +++++++++ 3 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/projects/github-connection-settings.tsx create mode 100644 apps/web/src/hooks/use-github-repo-search.ts diff --git a/apps/web/src/components/projects/github-connection-settings.tsx b/apps/web/src/components/projects/github-connection-settings.tsx new file mode 100644 index 0000000..3fb32d7 --- /dev/null +++ b/apps/web/src/components/projects/github-connection-settings.tsx @@ -0,0 +1,194 @@ +import { authClient } from "@code/auth/web"; +import { Button } from "@code/ui/components/button"; +import { + LoaderCircle, + RefreshCw, + ShieldCheck, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +/** Expanded scope set for repository + organization visibility. */ +const GITHUB_EXPANDED_SCOPES = [ + "repo", + "read:org", + "read:user", + "user:email", +] as const; + +interface GithubConnectionSettingsProps { + readonly connectionId: string; + readonly externalUsername: string | undefined; + readonly grantedScopesJson: string | undefined; +} + +export const GithubConnectionSettings = ({ + connectionId, + externalUsername, + grantedScopesJson, +}: GithubConnectionSettingsProps) => { + const [open, setOpen] = useState(false); + const [reauthorizing, setReauthorizing] = useState(false); + const popoverRef = useRef(null); + + // Close on outside click. + useEffect(() => { + if (!open) { + return; + } + const handleClickOutside = (event: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + // Parse granted scopes from stored JSON. + const grantedScopes: readonly string[] = (() => { + if (!grantedScopesJson) { + return []; + } + try { + const parsed = JSON.parse(grantedScopesJson) as unknown; + if (Array.isArray(parsed)) { + return parsed.filter((s): s is string => typeof s === "string"); + } + return []; + } catch { + return []; + } + })(); + + const missingScopes = GITHUB_EXPANDED_SCOPES.filter( + (scope) => !grantedScopes.includes(scope) + ); + + const handleReauthorize = async () => { + setReauthorizing(true); + try { + await authClient.linkSocial({ + callbackURL: `${window.location.origin}/projects?resume=github`, + provider: "github", + // Pass the full desired scope set so GitHub grants everything. + scopes: [...GITHUB_EXPANDED_SCOPES], + }); + } catch { + setReauthorizing(false); + } + }; + + // Silence unused-vars: connectionId identifies which row is being managed. + void connectionId; + + return ( +
+ + {open ? ( +
+
+
+ +

+ GitHub Access +

+
+ +
+ + {externalUsername ? ( +

+ Connected as{" "} + + {externalUsername} + +

+ ) : null} + + {grantedScopes.length > 0 ? ( +
+

+ Granted scopes +

+
+ {grantedScopes.map((scope) => ( + + {scope} + + ))} +
+
+ ) : ( +

+ Scope details will appear after reauthorization. +

+ )} + +

+ Reauthorization refreshes GitHub scopes and re-syncs your + accessible repositories, including organization repos GitHub + returns for your account. +

+ +

+ Organization repository visibility depends on your GitHub org + membership and SSO authorization — not all org repos may be + accessible via OAuth alone. +

+ + + + {missingScopes.length > 0 ? ( +

+ Not yet granted: {missingScopes.join(", ")} +

+ ) : null} +
+ ) : null} +
+ ); +}; diff --git a/apps/web/src/components/projects/provider-chips.tsx b/apps/web/src/components/projects/provider-chips.tsx index bf17774..63acbe5 100644 --- a/apps/web/src/components/projects/provider-chips.tsx +++ b/apps/web/src/components/projects/provider-chips.tsx @@ -9,10 +9,12 @@ import { } from "lucide-react"; import { useState } from "react"; +import { GithubConnectionSettings } from "./github-connection-settings"; import { PuterConnectForm } from "./puter-connect-form"; export interface GitProviderAccountOption { readonly externalUsername: string; + readonly grantedScopesJson?: string; readonly id: string; readonly provider: "github" | "gitea"; readonly serverUrl: string; @@ -125,16 +127,23 @@ export const ProviderChips = ({ accounts }: ProviderChipsProps) => { const accountsLoading = accounts === undefined; - return ( -
-
- +
+
+ + {githubConnected ? ( + + ) : null} +
{ + const searchAction = useAction(api.gitConnections.searchGithubRepositories); + const [state, setState] = useState({ kind: "idle" }); + // Track the latest request so stale responses are discarded. + const requestIdRef = useRef(0); + + useEffect(() => { + const trimmed = query.trim(); + + // If search is disabled or query too short, reset to idle. + if (!enabled || trimmed.length < MIN_QUERY_LENGTH) { + setState({ kind: "idle" }); + return; + } + if (!connectionId) { + setState({ kind: "idle" }); + return; + } + + const currentRequestId = requestIdRef.current + 1; + requestIdRef.current = currentRequestId; + setState({ kind: "loading" }); + + const timer = setTimeout(() => { + void (async () => { + try { + const results = await searchAction({ + connectionId, + query: trimmed, + }); + // Discard if a newer request superseded this one. + if (requestIdRef.current !== currentRequestId) { + return; + } + setState({ kind: "results", results }); + } catch (caughtError) { + if (requestIdRef.current !== currentRequestId) { + return; + } + const message = + caughtError instanceof Error + ? caughtError.message + : "GitHub search failed"; + if ( + message.toLowerCase().includes("reauth") || + message.toLowerCase().includes("rejected") || + message.toLowerCase().includes("reconnect") + ) { + setState({ kind: "reauth" }); + } else { + setState({ kind: "error", message }); + } + } + })(); + }, DEBOUNCE_MS); + + return () => { + clearTimeout(timer); + }; + }, [connectionId, enabled, query, searchAction]); + + return state; +};