From 342c01fee8394324e7adc52b8338c683e615e6bd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:51:37 -0500 Subject: [PATCH] fix(connections): simplify GitHub access details (#12893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Connections show the identity and access that agents use > - The GitHub permissions page showed several operational fields in one large status card > - That card made the repository actions harder to scan > - A dedicated GitHub identity also named its agent without linking to the agent > - The permanent shell Git warning also competed with the controls it explained > - This pull request replaces the card with two compact action rows, links the agent label, and reveals the warning only when an action permission is changed > - The benefit is a shorter page with direct navigation to the controls that matter ## Linked Issues or Issue Description Related: #12891 **What existing behavior does this improve?** The GitHub connection permissions view. **Subsystem affected** `ui/` — React and Vite board UI. **Current behavior** The view shows installation, token, webhook, event, and refresh metadata in a large card. The dedicated-agent text is not interactive, and a shell Git warning is always visible even before the user interacts with action permissions. **Proposed behavior** Show one repository-management row and one access-refresh row. Link the dedicated-agent text to that agent. Hide the shell Git warning until the user changes an action permission, then show it in the Actions section. **Reason and benefit** The two actions are easier to find. Users can open the dedicated agent directly, and see the shell Git limitation at the moment it becomes relevant. **Breaking changes** None. This change removes secondary display fields from this view. It does not change GitHub credentials, grants, or API data. ## What Changed - Replaced the GitHub status card with repository and refresh rows. - Kept the token-backed all-repositories warning inside the repository row. - Added explicit labels for selected, all, mixed, and empty repository access. - Added a direct link from “Used only by” to the dedicated agent. - Moved the shell Git/`gh` warning into the Actions section and reveal it only after a permission-change attempt. - Added render coverage for the rows, removed fields, links, actions, and contextual warning. ## Verification - `pnpm exec vitest run ui/src/pages/apps/AppDetail.test.tsx` — 50 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/ui build` — passed. - `pnpm check:token-gates` — passed. - Verified the live page initially hides the shell warning, then shows it after changing an action permission; restored the test permission afterward. ## Risks Low risk. This is a display-only change. The existing management URL, refresh action, and action-permission mutations are unchanged. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code execution, browser control, and multi-file repository editing. The context window size was not provided. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/pages/apps/AppDetail.test.tsx | 121 ++++++++++++++++++ ui/src/pages/apps/AppDetail.tsx | 5 + .../apps/app-detail/IdentitiesSection.tsx | 71 ++++++---- .../apps/app-detail/PermissionsPanel.tsx | 24 +++- 4 files changed, 195 insertions(+), 26 deletions(-) diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 5c44bd4fdc..e5c45cadfc 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -262,6 +262,36 @@ function personalGrant(overrides: Record = {}) { }; } +function dedicatedGitHubGrant( + overrides: Record = {}, + githubOverrides: Record = {}, +) { + return organizationGrant({ + id: "grant-agent", + kind: "agent", + subjectAgentId: "agent-1", + subjectUserId: null, + isDefault: false, + providerTenant: { + github: { + userId: "123", + login: "dottabot", + installationCount: 1, + repositoryCount: 1, + repositorySelection: "selected", + installationIds: ["456"], + installationOwnerLogins: ["paperclipai"], + managementUrl: "https://github.com/settings/installations/456", + webhookHealth: "pending", + lastWebhookAt: null, + lastAccessRefreshAt: "2026-09-05T12:00:00.000Z", + ...githubOverrides, + }, + }, + ...overrides, + }); +} + function catalogEntry(overrides: Record = {}) { return { id: "catalog-read", @@ -1425,6 +1455,97 @@ describe("AppDetail", () => { expect(findButton("Revoke")).toBeUndefined(); }); + it("shows dedicated GitHub access as compact action rows and links to the agent", async () => { + mockParams.tab = "permissions"; + getConnectionMock.mockResolvedValue(connection({ + credentialPolicy: "per_agent", + authKind: "oauth", + })); + listConnectionGrantsMock.mockResolvedValue({ + connection: { id: "conn-1", uid: "conn-1" }, + grants: [dedicatedGitHubGrant()], + capabilities: fullCapabilities(), + currentUserId: "user-1", + members: [], + }); + + await renderAppDetail(); + + expect(container.querySelector('a[href="/agents/coder"]')?.textContent).toContain("Used only by Coder"); + expect(container.textContent).toContain("Repositories"); + expect(container.textContent).toContain("1 selected repositories"); + expect(container.querySelector( + 'a[href="https://github.com/settings/installations/456"]', + )?.textContent).toBe("Manage repositories on GitHub"); + expect(findButton("Refresh access")).toBeTruthy(); + expect(container.textContent).not.toContain("Installation"); + expect(container.textContent).not.toContain("Token continuity"); + expect(container.textContent).not.toContain("Webhook health"); + expect(container.textContent).not.toContain("Last event"); + expect(container.textContent).not.toContain("Last access refresh"); + expect(container.textContent).not.toContain("Shell Git and gh use this account"); + + const askFirst = container.querySelector('button[aria-label="Read repo: Ask first"]'); + expect(askFirst).toBeTruthy(); + await act(async () => { + askFirst!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(container.textContent).toContain( + "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls.", + ); + }); + + it("warns about all-repository GitHub access within the repository row", async () => { + mockParams.tab = "permissions"; + getConnectionMock.mockResolvedValue(connection({ + credentialPolicy: "per_agent", + authKind: "oauth", + })); + listConnectionGrantsMock.mockResolvedValue({ + connection: { id: "conn-1", uid: "conn-1" }, + grants: [dedicatedGitHubGrant({}, { + repositoryCount: 0, + repositorySelection: "all", + })], + capabilities: fullCapabilities(), + currentUserId: "user-1", + members: [], + }); + + await renderAppDetail(); + + expect(container.textContent).toContain("All current and future repositories"); + expect(container.textContent).not.toContain("selected repositories"); + }); + + it.each([ + ["mixed", "Mixed access; scope varies by installation"], + ["none", "No repositories selected"], + ] as const)("labels %s GitHub repository access explicitly", async (repositorySelection, expected) => { + mockParams.tab = "permissions"; + getConnectionMock.mockResolvedValue(connection({ + credentialPolicy: "per_agent", + authKind: "oauth", + })); + listConnectionGrantsMock.mockResolvedValue({ + connection: { id: "conn-1", uid: "conn-1" }, + grants: [dedicatedGitHubGrant({}, { + repositoryCount: 0, + repositorySelection, + })], + capabilities: fullCapabilities(), + currentUserId: "user-1", + members: [], + }); + + await renderAppDetail(); + + expect(container.textContent).toContain(expected); + expect(container.textContent).not.toContain("selected repositories"); + }); + it("persists an empty audience as all organization members", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" })); diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 721f8af971..bde7e92ba9 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -602,6 +602,11 @@ export function AppDetail() { askFirstIds={askFirstIds} pending={pending} refreshPending={refreshTools.isPending} + permissionChangeWarning={ + connection.credentialPolicy === "per_agent" && managedIdentityGrant?.providerTenant?.github + ? "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls." + : undefined + } onSaveAccess={(next) => apply({ access: accessIncludingInstalls(next, install) })} onRefreshActions={() => refreshTools.mutate()} onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))} diff --git a/ui/src/pages/apps/app-detail/IdentitiesSection.tsx b/ui/src/pages/apps/app-detail/IdentitiesSection.tsx index 25d8cdd2c3..2f49e0c15a 100644 --- a/ui/src/pages/apps/app-detail/IdentitiesSection.tsx +++ b/ui/src/pages/apps/app-detail/IdentitiesSection.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { Building2, Loader2, UserRound } from "lucide-react"; +import { Building2, Loader2, TriangleAlert, UserRound } from "lucide-react"; import type { ConnectionAudienceMember, ConnectionGrant, @@ -30,8 +30,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { cn } from "@/lib/utils"; -import { brandChipBadge } from "@/lib/status-colors"; +import { Link } from "@/lib/router"; +import { brandBanner, brandChipBadge } from "@/lib/status-colors"; +import { agentUrl, cn } from "@/lib/utils"; import { audienceUserIds, grantAccountLabel, @@ -98,7 +99,7 @@ export function IdentitiesSection({ credentialPolicy: ToolConnectionCredentialPolicy; ownerUserId: string | null; connectedUser: { label: string; image: string | null } | null; - dedicatedAgent: { id: string; name: string } | null; + dedicatedAgent: { id: string; name: string; urlKey?: string | null } | null; grantsQuery: ConnectionGrantsResponse | undefined; loading: boolean; error: boolean; @@ -178,7 +179,14 @@ export function IdentitiesSection({ + Used only by {dedicatedAgent.name} + + ) : "Dedicated to one agent"} actions={!agentGrant && dedicatedAgent && capabilities?.canConfigure ? ( ) : null} + +
+
+
Refresh access
+
Sync repository access from GitHub.
+
{onRefreshAccess ? (