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 ? (