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.
This commit is contained in:
194
apps/web/src/components/projects/github-connection-settings.tsx
Normal file
194
apps/web/src/components/projects/github-connection-settings.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="relative" ref={popoverRef}>
|
||||
<Button
|
||||
aria-label="GitHub connection settings"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-8 gap-1 rounded border-[#d7d3c7] bg-[#fffefa] px-0 text-[#747168] hover:bg-[#f5f3eb]"
|
||||
disabled={reauthorizing}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div
|
||||
aria-modal="false"
|
||||
className="absolute top-9 right-0 z-50 w-72 rounded-lg border border-[#d7d3c7] bg-[#fffefa] p-4 shadow-xl"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="size-4 text-emerald-500" />
|
||||
<h4 className="text-sm font-semibold text-[#20201d]">
|
||||
GitHub Access
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close settings"
|
||||
className="text-[#858277] hover:text-[#20201d]"
|
||||
onClick={() => setOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{externalUsername ? (
|
||||
<p className="mt-2 text-xs text-[#68665e]">
|
||||
Connected as{" "}
|
||||
<span className="font-medium text-[#20201d]">
|
||||
{externalUsername}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{grantedScopes.length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-[#858277]">
|
||||
Granted scopes
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{grantedScopes.map((scope) => (
|
||||
<span
|
||||
className="rounded bg-[#f5f3eb] px-1.5 py-0.5 text-[10px] font-medium text-[#68665e]"
|
||||
key={scope}
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-[#858277]">
|
||||
Scope details will appear after reauthorization.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs leading-5 text-[#68665e]">
|
||||
Reauthorization refreshes GitHub scopes and re-syncs your
|
||||
accessible repositories, including organization repos GitHub
|
||||
returns for your account.
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-[11px] leading-4 text-[#858277]">
|
||||
Organization repository visibility depends on your GitHub org
|
||||
membership and SSO authorization — not all org repos may be
|
||||
accessible via OAuth alone.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="mt-3 w-full gap-1.5 rounded border-[#55564e] bg-[#20201d] text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
disabled={reauthorizing}
|
||||
onClick={() => void handleReauthorize()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{reauthorizing ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
{reauthorizing ? "Redirecting…" : "Reauthorize access"}
|
||||
</Button>
|
||||
|
||||
{missingScopes.length > 0 ? (
|
||||
<p className="mt-2 text-[10px] leading-4 text-amber-600">
|
||||
Not yet granted: {missingScopes.join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ProviderChip
|
||||
account={githubAccount}
|
||||
icon={GitBranch}
|
||||
label="GitHub"
|
||||
loading={accountsLoading || pendingProvider === "github"}
|
||||
onClick={linkGithub}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ProviderChip
|
||||
account={githubAccount}
|
||||
icon={GitBranch}
|
||||
label="GitHub"
|
||||
loading={accountsLoading || pendingProvider === "github"}
|
||||
onClick={linkGithub}
|
||||
/>
|
||||
{githubConnected ? (
|
||||
<GithubConnectionSettings
|
||||
connectionId={githubAccount.id}
|
||||
externalUsername={githubAccount.externalUsername}
|
||||
grantedScopesJson={githubAccount.grantedScopesJson}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ProviderChip
|
||||
account={puterAccount}
|
||||
icon={Server}
|
||||
|
||||
99
apps/web/src/hooks/use-github-repo-search.ts
Normal file
99
apps/web/src/hooks/use-github-repo-search.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type {
|
||||
GithubRepositorySearchResult,
|
||||
} from "@code/backend/convex/gitConnections";
|
||||
import { useAction } from "convex/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type { GithubRepositorySearchResult };
|
||||
|
||||
export type GithubSearchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "results"; results: readonly GithubRepositorySearchResult[] }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "reauth" };
|
||||
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
const DEBOUNCE_MS = 350;
|
||||
|
||||
/**
|
||||
* Debounced live GitHub repository search. Calls the
|
||||
* `gitConnections:searchGithubRepositories` action with the user's stored
|
||||
* encrypted credential (the token never reaches the browser). Returns a
|
||||
* stale-safe, cancellable search state.
|
||||
*
|
||||
* Only searches when the GitHub provider filter is active (or "all") and a
|
||||
* query is at least `MIN_QUERY_LENGTH` characters.
|
||||
*/
|
||||
export const useGithubRepoSearch = ({
|
||||
connectionId,
|
||||
enabled,
|
||||
query,
|
||||
}: {
|
||||
readonly connectionId: string | undefined;
|
||||
readonly enabled: boolean;
|
||||
readonly query: string;
|
||||
}): GithubSearchState => {
|
||||
const searchAction = useAction(api.gitConnections.searchGithubRepositories);
|
||||
const [state, setState] = useState<GithubSearchState>({ 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;
|
||||
};
|
||||
Reference in New Issue
Block a user