-
-
Notifications
You must be signed in to change notification settings - Fork 35
feat(secrets): per-agent GitHub token grant UI + short-lived token storage #2036
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
328c5bc
014476d
75d09eb
fcaeb03
426a870
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||||||
| import { useState, useEffect, useCallback, useRef } from "react"; | ||||||||||
| import { Github, Copy, Check, ExternalLink, Trash2, Plus, Loader2, Settings, X } from "lucide-react"; | ||||||||||
| import { Github, Copy, Check, ExternalLink, Trash2, Plus, Loader2, Settings, X, Save } from "lucide-react"; | ||||||||||
| import { Button, Card, CardContent } from "@/components/ui"; | ||||||||||
| import { | ||||||||||
| startDeviceFlow, | ||||||||||
|
|
@@ -76,18 +76,109 @@ export function GitHubConnect() { | |||||||||
| [refreshInstallations], | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| // -- Per-agent GitHub repo grants ---------------------------------- | ||||||||||
|
|
||||||||||
| const [repoAgents, setRepoAgents] = useState<Record<string, string>>({}); | ||||||||||
| const [savingGrants, setSavingGrants] = useState<Set<string>>(new Set()); | ||||||||||
| const [saveErrors, setSaveErrors] = useState<Record<string, string>>({}); | ||||||||||
|
|
||||||||||
| const fetchAgentGrants = useCallback(async () => { | ||||||||||
| // Fetch existing github-installation secrets for all known repos. | ||||||||||
| try { | ||||||||||
| const res = await fetch("/api/secrets?category=github-installation", { | ||||||||||
| headers: { Accept: "application/json" }, | ||||||||||
| }); | ||||||||||
| if (!res.ok) return; | ||||||||||
| const ct = res.headers.get("content-type") ?? ""; | ||||||||||
| if (!ct.includes("application/json")) return; | ||||||||||
| const data = await res.json(); | ||||||||||
| if (Array.isArray(data)) { | ||||||||||
| const grants: Record<string, string> = {}; | ||||||||||
| for (const s of data) { | ||||||||||
| if (s.name && s.agents && s.agents.length > 0) { | ||||||||||
| grants[s.name] = s.agents.join(", "); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| setRepoAgents(grants); | ||||||||||
| } | ||||||||||
| } catch { /* ignore */ } | ||||||||||
| }, []); | ||||||||||
|
|
||||||||||
| const handleSaveGrants = useCallback( | ||||||||||
| async (repoFullName: string, installationId: number, permissions: string[]) => { | ||||||||||
| setSavingGrants((prev) => new Set(prev).add(repoFullName)); | ||||||||||
| setSaveErrors((prev) => { | ||||||||||
| const next = { ...prev }; | ||||||||||
| delete next[repoFullName]; | ||||||||||
| return next; | ||||||||||
| }); | ||||||||||
| try { | ||||||||||
| const agentsStr = repoAgents[repoFullName] || ""; | ||||||||||
| const agents = agentsStr | ||||||||||
| .split(",") | ||||||||||
| .map((a) => a.trim()) | ||||||||||
| .filter(Boolean); | ||||||||||
|
|
||||||||||
| const body = JSON.stringify({ | ||||||||||
| name: repoFullName, | ||||||||||
| category: "github-installation", | ||||||||||
| value: JSON.stringify({ | ||||||||||
| installation_id: installationId, | ||||||||||
| repo_full_name: repoFullName, | ||||||||||
| permissions, | ||||||||||
| }), | ||||||||||
| description: `GitHub App installation for ${repoFullName}`, | ||||||||||
| agents, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| // Try create first; if exists, update. | ||||||||||
| let res = await fetch("/api/secrets", { | ||||||||||
| method: "POST", | ||||||||||
| headers: { "Content-Type": "application/json", Accept: "application/json" }, | ||||||||||
| body, | ||||||||||
| }); | ||||||||||
| if (res.status === 409) { | ||||||||||
| // Already exists — update instead | ||||||||||
| res = await fetch(`/api/secrets/${encodeURIComponent(repoFullName)}`, { | ||||||||||
| method: "PUT", | ||||||||||
| headers: { "Content-Type": "application/json", Accept: "application/json" }, | ||||||||||
| body: JSON.stringify({ agents, value: JSON.stringify({ | ||||||||||
| installation_id: installationId, | ||||||||||
| repo_full_name: repoFullName, | ||||||||||
| permissions, | ||||||||||
| }) }), | ||||||||||
| }); | ||||||||||
| } | ||||||||||
| } catch { | ||||||||||
| setSaveErrors((prev) => ({ | ||||||||||
| ...prev, | ||||||||||
| [repoFullName]: "Save failed — please try again.", | ||||||||||
| })); | ||||||||||
| } finally { | ||||||||||
| setSavingGrants((prev) => { | ||||||||||
| const next = new Set(prev); | ||||||||||
| next.delete(repoFullName); | ||||||||||
| return next; | ||||||||||
| }); | ||||||||||
| } | ||||||||||
| }, | ||||||||||
| [repoAgents], | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| useEffect(() => { | ||||||||||
| refreshIdentities(); | ||||||||||
| refreshInstallations(); | ||||||||||
| fetchAgentGrants(); | ||||||||||
|
|
||||||||||
| // Refresh when the user returns from the external GitHub App install flow | ||||||||||
| const onFocus = () => { | ||||||||||
| refreshIdentities(); | ||||||||||
| refreshInstallations(); | ||||||||||
| fetchAgentGrants(); | ||||||||||
| }; | ||||||||||
| window.addEventListener("focus", onFocus); | ||||||||||
| return () => window.removeEventListener("focus", onFocus); | ||||||||||
| }, [refreshIdentities, refreshInstallations]); | ||||||||||
| }, [refreshIdentities, refreshInstallations, fetchAgentGrants]); | ||||||||||
|
|
||||||||||
| const stopPolling = useCallback(() => { | ||||||||||
| if (pollTimer.current) clearTimeout(pollTimer.current); | ||||||||||
|
|
@@ -351,16 +442,52 @@ export function GitHubConnect() { | |||||||||
| </Button> | ||||||||||
| </div> | ||||||||||
| {inst.repositories.length > 0 && ( | ||||||||||
| <div className="ml-7 space-y-1"> | ||||||||||
| <div className="ml-7 space-y-3"> | ||||||||||
| {inst.repositories.map((repo) => ( | ||||||||||
| <div | ||||||||||
| key={repo.full_name} | ||||||||||
| className="flex items-center gap-1.5 text-xs text-shell-text-secondary" | ||||||||||
| > | ||||||||||
| <span className="text-shell-text-tertiary"> | ||||||||||
| {repo.private ? "🔒" : "📁"} | ||||||||||
| </span> | ||||||||||
| <span className="font-mono">{repo.full_name}</span> | ||||||||||
| <div key={repo.full_name} className="space-y-1.5"> | ||||||||||
| <div className="flex items-center gap-1.5 text-xs text-shell-text-secondary"> | ||||||||||
| <span className="text-shell-text-tertiary"> | ||||||||||
| {repo.private ? "🔒" : "📁"} | ||||||||||
| </span> | ||||||||||
| <span className="font-mono">{repo.full_name}</span> | ||||||||||
| </div> | ||||||||||
| <div className="flex items-center gap-2"> | ||||||||||
| <input | ||||||||||
| type="text" | ||||||||||
| placeholder="agent names, comma-separated" | ||||||||||
| className="flex-1 h-8 rounded-lg border border-white/10 bg-shell-bg-deep px-2.5 text-xs text-shell-text placeholder:text-shell-text-tertiary focus:outline-none focus:border-accent/40 focus:ring-2 focus:ring-accent/20" | ||||||||||
| value={repoAgents[repo.full_name] || ""} | ||||||||||
| onChange={(e) => | ||||||||||
| setRepoAgents((prev) => ({ | ||||||||||
| ...prev, | ||||||||||
| [repo.full_name]: e.target.value, | ||||||||||
| })) | ||||||||||
| } | ||||||||||
| aria-label={`Agents for ${repo.full_name}`} | ||||||||||
| /> | ||||||||||
| <Button | ||||||||||
| variant="ghost" | ||||||||||
| size="icon" | ||||||||||
| className="h-8 w-8 shrink-0" | ||||||||||
| onClick={() => | ||||||||||
| handleSaveGrants(repo.full_name, inst.id, inst.permissions ?? []) | ||||||||||
| } | ||||||||||
| aria-label={`Save agent grants for ${repo.full_name}`} | ||||||||||
| title={`Save agent grants for ${repo.full_name}`} | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION:
Suggested change
As written, every saved Reply with |
||||||||||
| disabled={savingGrants.has(repo.full_name)} | ||||||||||
| > | ||||||||||
| {savingGrants.has(repo.full_name) ? ( | ||||||||||
| <Loader2 size={14} className="animate-spin" /> | ||||||||||
| ) : ( | ||||||||||
| <Save size={14} /> | ||||||||||
| )} | ||||||||||
| </Button> | ||||||||||
| </div> | ||||||||||
| {saveErrors[repo.full_name] && ( | ||||||||||
| <p className="text-xs text-red-400" role="alert"> | ||||||||||
| {saveErrors[repo.full_name]} | ||||||||||
| </p> | ||||||||||
| )} | ||||||||||
| </div> | ||||||||||
| ))} | ||||||||||
| </div> | ||||||||||
|
|
||||||||||
Uh oh!
There was an error while loading. Please reload this page.