fix(connections): simplify GitHub access details (#12893)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-05 09:51:37 -05:00 committed by GitHub
parent 87832c48fd
commit 342c01fee8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 195 additions and 26 deletions

View File

@ -262,6 +262,36 @@ function personalGrant(overrides: Record<string, unknown> = {}) {
};
}
function dedicatedGitHubGrant(
overrides: Record<string, unknown> = {},
githubOverrides: Record<string, unknown> = {},
) {
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<string, unknown> = {}) {
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<HTMLButtonElement>('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" }));

View File

@ -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))}

View File

@ -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({
<IdentityRow
title={github ? `@${github.login}` : "Dedicated GitHub account"}
status={agentGrant?.status ?? null}
detail={dedicatedAgent ? `Used only by ${dedicatedAgent.name}` : "Dedicated to one agent"}
detail={dedicatedAgent ? (
<Link
to={agentUrl(dedicatedAgent)}
className="transition-colors hover:text-foreground hover:underline"
>
Used only by {dedicatedAgent.name}
</Link>
) : "Dedicated to one agent"}
actions={!agentGrant && dedicatedAgent && capabilities?.canConfigure ? (
<Button size="sm" disabled={connectPending} onClick={() => onConnectAgent(dedicatedAgent.id)}>
{connectPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
@ -187,9 +195,6 @@ export function IdentitiesSection({
) : null}
/>
{github ? <GitHubConnectionSummary grant={agentGrant} onRefreshAccess={onRefreshAccess} refreshPending={refreshAccessPending} /> : null}
<InlineBanner tone="warning" compact>
Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls.
</InlineBanner>
</section>
);
}
@ -288,27 +293,45 @@ function GitHubConnectionSummary({
}) {
const github = grant.providerTenant?.github;
if (!github) return null;
const repositoryWarning = github.repositorySelection === "all"
? "All current and future repositories"
: github.repositorySelection === "mixed"
? "Mixed access; scope varies by installation"
: null;
const repositorySummary = github.repositorySelection === "none"
? "No repositories selected"
: `${github.repositoryCount} selected repositories`;
return (
<div className="space-y-4 rounded-lg border border-border p-4">
<div className="grid gap-3 text-sm text-muted-foreground sm:grid-cols-2">
<p><span className="font-medium text-foreground">Installation</span><br />{github.installationOwnerLogins.join(", ") || "GitHub"}</p>
<p><span className="font-medium text-foreground">Repositories</span><br />{github.repositoryCount} · {github.repositorySelection === "all" ? "All repositories" : "Selected repositories"}</p>
<p><span className="font-medium text-foreground">Token continuity</span><br />{grant.providerTenant?.oauth?.accessTokenExpiresAt ? "Automatically refreshed" : "Long-lived"}</p>
<p><span className="font-medium text-foreground">Webhook health</span><br />{github.webhookHealth === "healthy" ? "Healthy" : github.webhookHealth === "unhealthy" ? "Needs attention" : "Pending first event"}</p>
<p><span className="font-medium text-foreground">Last event</span><br />{github.lastWebhookAt ? new Date(github.lastWebhookAt).toLocaleString() : "No event received yet"}</p>
<p><span className="font-medium text-foreground">Last access refresh</span><br />{github.lastAccessRefreshAt ? new Date(github.lastAccessRefreshAt).toLocaleString() : "Not refreshed yet"}</p>
</div>
{github.repositorySelection === "all" ? (
<InlineBanner tone="warning" compact>
This installation can access every current and future repository in its GitHub account. Selected repositories is the safer default.
</InlineBanner>
) : null}
<div className="flex flex-wrap gap-2">
<div className="divide-y divide-border border-y border-border">
<div className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Repositories</div>
{repositoryWarning ? (
<div
role="note"
className={cn(
"mt-1 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs",
brandBanner.warning,
)}
>
<TriangleAlert className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{repositoryWarning}
</div>
) : (
<div className="text-xs text-muted-foreground">{repositorySummary}</div>
)}
</div>
{github.managementUrl ? (
<Button asChild size="sm" variant="outline">
<a href={github.managementUrl} target="_blank" rel="noreferrer">Manage repositories on GitHub</a>
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Refresh access</div>
<div className="text-xs text-muted-foreground">Sync repository access from GitHub.</div>
</div>
{onRefreshAccess ? (
<Button size="sm" variant="outline" disabled={refreshPending} onClick={onRefreshAccess}>
{refreshPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
@ -398,7 +421,7 @@ function IdentityRow({
id?: string;
title: string;
status: ConnectionGrant["status"] | null;
detail: string | null;
detail: ReactNode;
actions: ReactNode;
}) {
return (

View File

@ -4,6 +4,7 @@ import type { Agent, ToolCatalogEntry, ToolConnectionCapabilities } from "@paper
import { useSearchParams } from "@/lib/router";
import { AgentIcon } from "@/components/AgentIconPicker";
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
import { InlineBanner } from "@/components/InlineBanner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RadioCardGroup } from "@/components/ui/radio-card";
@ -35,6 +36,7 @@ export function PermissionsPanel({
onRefreshActions,
refreshPending,
capabilities,
permissionChangeWarning,
}: Pick<
AppDetailSectionProps,
| "appName"
@ -55,6 +57,7 @@ export function PermissionsPanel({
onRefreshActions: () => void;
refreshPending: boolean;
capabilities: ToolConnectionCapabilities | undefined;
permissionChangeWarning?: string;
}) {
const [searchParams] = useSearchParams();
return (
@ -68,6 +71,7 @@ export function PermissionsPanel({
onSave={onSaveAccess}
/>
<ActionsSection
key={connectionId}
connectionId={connectionId}
appName={appName}
readOnly={readOnly}
@ -79,6 +83,7 @@ export function PermissionsPanel({
refreshPending={refreshPending}
focusId={searchParams.get("focus")}
canConfigure={capabilities?.canConfigure ?? false}
permissionChangeWarning={permissionChangeWarning}
onSetPermission={onSetActionPermission}
onReviewQuarantined={onReviewQuarantined}
onRefreshActions={onRefreshActions}
@ -197,6 +202,7 @@ function ActionsSection({
refreshPending,
focusId,
canConfigure,
permissionChangeWarning,
onSetPermission,
onReviewQuarantined,
onRefreshActions,
@ -212,12 +218,14 @@ function ActionsSection({
refreshPending: boolean;
focusId?: string | null;
canConfigure: boolean;
permissionChangeWarning?: string;
onSetPermission: (id: string, next: ActionPermission) => void;
onReviewQuarantined: (enabledIds: string[]) => void;
onRefreshActions: () => void;
}) {
const [query, setQuery] = useState("");
const [kindFilter, setKindFilter] = useState<ActionKindFilter>("all");
const [showPermissionChangeWarning, setShowPermissionChangeWarning] = useState(false);
const byName = (a: ToolCatalogEntry, b: ToolCatalogEntry) =>
(a.title ?? a.toolName).localeCompare(b.title ?? b.toolName);
const sortedRead = useMemo(() => [...readOnly].sort(byName), [readOnly]);
@ -264,6 +272,12 @@ function ActionsSection({
/>
) : null}
{permissionChangeWarning && showPermissionChangeWarning ? (
<InlineBanner tone="warning" compact>
{permissionChangeWarning}
</InlineBanner>
) : null}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-(--sz-12rem) flex-1">
@ -299,7 +313,10 @@ function ActionsSection({
disabled={disabled}
focusId={focusId}
canConfigure={canConfigure}
onSetPermission={onSetPermission}
onSetPermission={(id, next) => {
setShowPermissionChangeWarning(true);
onSetPermission(id, next);
}}
/>
<ActionGroup
title={`Write (${visibleWrite.length})`}
@ -311,7 +328,10 @@ function ActionsSection({
disabled={disabled}
focusId={focusId}
canConfigure={canConfigure}
onSetPermission={onSetPermission}
onSetPermission={(id, next) => {
setShowPermissionChangeWarning(true);
onSetPermission(id, next);
}}
/>
</div>
)}