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";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { GithubConnectionSettings } from "./github-connection-settings";
|
||||||
import { PuterConnectForm } from "./puter-connect-form";
|
import { PuterConnectForm } from "./puter-connect-form";
|
||||||
|
|
||||||
export interface GitProviderAccountOption {
|
export interface GitProviderAccountOption {
|
||||||
readonly externalUsername: string;
|
readonly externalUsername: string;
|
||||||
|
readonly grantedScopesJson?: string;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly provider: "github" | "gitea";
|
readonly provider: "github" | "gitea";
|
||||||
readonly serverUrl: string;
|
readonly serverUrl: string;
|
||||||
@@ -125,16 +127,23 @@ export const ProviderChips = ({ accounts }: ProviderChipsProps) => {
|
|||||||
|
|
||||||
const accountsLoading = accounts === undefined;
|
const accountsLoading = accounts === undefined;
|
||||||
|
|
||||||
return (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="space-y-3">
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex flex-wrap gap-2">
|
<ProviderChip
|
||||||
<ProviderChip
|
account={githubAccount}
|
||||||
account={githubAccount}
|
icon={GitBranch}
|
||||||
icon={GitBranch}
|
label="GitHub"
|
||||||
label="GitHub"
|
loading={accountsLoading || pendingProvider === "github"}
|
||||||
loading={accountsLoading || pendingProvider === "github"}
|
onClick={linkGithub}
|
||||||
onClick={linkGithub}
|
/>
|
||||||
/>
|
{githubConnected ? (
|
||||||
|
<GithubConnectionSettings
|
||||||
|
connectionId={githubAccount.id}
|
||||||
|
externalUsername={githubAccount.externalUsername}
|
||||||
|
grantedScopesJson={githubAccount.grantedScopesJson}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<ProviderChip
|
<ProviderChip
|
||||||
account={puterAccount}
|
account={puterAccount}
|
||||||
icon={Server}
|
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