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:
parent
87832c48fd
commit
342c01fee8
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue