mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix(github): simplify App reconnection and credential recovery (#16856)
Show clear GitHub connection recovery guidance and reconnect the saved App before requesting replacement credentials. Preserve PEM bytes through paste, upload, and retry, with production-page Storybook and browser regression coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d20cb69ce5
commit
5245959023
10 files changed
+401
-104
No files matched your search
@@ -103,6 +103,15 @@ must remain stopped while transient disconnections can still recover.
|
||||
The PostgreSQL suite uses controlled provider failures for these cases; live
|
||||
provider credential repair remains a separate acceptance check.
|
||||
|
||||
GitHub connections in attention or revoked state show a repair notice above the
|
||||
settings. **Reconnect GitHub → Reconnect saved App** checks the same App with
|
||||
stored credentials and repairs delivery. **Replace App credentials** is the
|
||||
fallback, with the saved App ID, a link to its GitHub settings, and PEM upload or
|
||||
paste instructions. The production management Storybook's **04 Recovery** group
|
||||
covers these views, pending/failure/success, mobile, and light theme with simulated
|
||||
provider responses. Verify actual credential validity and restored delivery
|
||||
separately against GitHub; a Storybook success does not establish live recovery.
|
||||
|
||||
For GitHub, submit a disposable PR and verify the summary heading uses the bot's
|
||||
display name and the check remains **Paperclip Review**. For two separate bots on one PR, verify
|
||||
their App-scoped checks and comments remain independent.
|
||||
|
||||
@@ -54,12 +54,25 @@ async function exerciseGitHubReviewSetup(page: Page, mock: ChatMock, seed: Seed,
|
||||
// Existing-App recovery stays available and preserves entered credentials after failure.
|
||||
await page.getByRole("button", { name: "I already have an App" }).click();
|
||||
await page.getByLabel("App ID", { exact: true }).fill("123456");
|
||||
await page.getByLabel("Private key", { exact: true }).fill(GITHUB_PRIVATE_KEY_PASTE_FIXTURE);
|
||||
await page.getByLabel("Private key", { exact: true }).evaluate((element, privateKey) => {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.setData("text/plain", privateKey);
|
||||
element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData }));
|
||||
}, GITHUB_PRIVATE_KEY_PASTE_FIXTURE);
|
||||
await page.getByLabel("Webhook secret", { exact: true }).fill("github-webhook-secret");
|
||||
const rejectedAppResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/api/chat-endpoints/endpoint-github/github/app") && response.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Connect existing App", exact: true }).click();
|
||||
expect((await rejectedAppResponse).request().postDataJSON().privateKey).toBe(GITHUB_PRIVATE_KEY_PASTE_FIXTURE);
|
||||
await expect(page.getByRole("alert")).toContainText("GitHub rejected the supplied App credentials.");
|
||||
await expect(page.getByLabel("Private key", { exact: true })).toHaveValue(GITHUB_PRIVATE_KEY_PASTE_FIXTURE);
|
||||
await expect(page.getByLabel("Private key", { exact: true })).toHaveValue("");
|
||||
await expect(page.getByText("Private key loaded. Paste or upload another key to replace it.", { exact: true })).toBeVisible();
|
||||
const retriedAppResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/api/chat-endpoints/endpoint-github/github/app") && response.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Connect existing App", exact: true }).click();
|
||||
expect((await retriedAppResponse).request().postDataJSON().privateKey).toBe(GITHUB_PRIVATE_KEY_PASTE_FIXTURE);
|
||||
await expect(page.getByRole("heading", { name: "Connect your account", exact: true })).toBeVisible();
|
||||
await page.getByLabel("Existing GitHub connection").selectOption("personal-github");
|
||||
await expect(page.getByRole("button", { name: "Check my account" })).toBeVisible();
|
||||
@@ -92,18 +105,19 @@ async function exerciseGitHubReviewSetup(page: Page, mock: ChatMock, seed: Seed,
|
||||
await expect.poll(() => mock.replayedDelivery).toBe(true);
|
||||
mock.setStatus("attention");
|
||||
await page.reload();
|
||||
await page.getByText("Connection health and controls", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connect GitHub", exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Leave these blank to reuse/)).toBeVisible();
|
||||
const recoveryNotice = page.getByRole("region", { name: "GitHub connection needs repair" });
|
||||
await expect(recoveryNotice).toBeVisible();
|
||||
await recoveryNotice.getByRole("link", { name: "Reconnect GitHub", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Reconnect GitHub", exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/using its saved credentials/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Choose an agent", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByLabel("Private key", { exact: true })).toHaveValue("");
|
||||
await expect(page.getByLabel("Private key", { exact: true })).toHaveCount(0);
|
||||
const reconnectResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/api/chat-endpoints/endpoint-github/setup") &&
|
||||
response.request().method() === "POST" &&
|
||||
response.request().postDataJSON().action === "reconnect",
|
||||
);
|
||||
await page.getByRole("button", { name: "Reconnect App", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Reconnect saved App", exact: true }).click();
|
||||
const reconnected = await reconnectResponse;
|
||||
expect(reconnected.request().postDataJSON()).toEqual({ action: "reconnect" });
|
||||
expect(await reconnected.json()).toMatchObject({ assignedAgentId: seed.agentId, assignedAgentName: "Maya" });
|
||||
|
||||
@@ -662,8 +662,12 @@ describe("chat setup and identity-link clipboard actions", () => {
|
||||
it("keeps manual existing-App recovery write-only without generating or copying credentials", async () => {
|
||||
await render("github");
|
||||
await click("I already have an App");
|
||||
expect(container.querySelector('input[type="password"]')).not.toBeNull();
|
||||
expect(container.querySelector("textarea")?.value).toBe("");
|
||||
const privateKey = container.querySelector<HTMLInputElement>("#github-private-key");
|
||||
const webhookSecret = container.querySelector<HTMLInputElement>("#github-webhook-secret");
|
||||
expect(privateKey?.type).toBe("password");
|
||||
expect(privateKey?.value).toBe("");
|
||||
expect(webhookSecret?.type).toBe("password");
|
||||
expect(webhookSecret?.value).toBe("");
|
||||
expect(container.textContent).not.toContain("Copy webhook secret");
|
||||
expect(container.textContent).not.toContain("Generate webhook secret");
|
||||
expect(mocks.generateSetupSecret).not.toHaveBeenCalled();
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions";
|
||||
import { ChatExecutionDefaults } from "./ChatExecutionDefaults";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { ChatConversationList } from "./ChatConversationList";
|
||||
import { GitHubConnectionRecoveryNotice } from "./GitHubConnectionRecovery";
|
||||
import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement";
|
||||
import { GitHubBotMention, GitHubAppIdentityActions } from "./GitHubAppIdentity";
|
||||
import { GitHubConnectionComplete, type GitHubConnectionCompleteLocationState } from "./GitHubConnectionComplete";
|
||||
@@ -372,6 +373,7 @@ export function ChatEndpointDetail() {
|
||||
{endpoint.status !== "active" && <StatusBadge status={endpoint.status} className={endpoint.provider === "speko" ? "text-foreground" : undefined} />}
|
||||
</div>
|
||||
</header>
|
||||
{endpoint.provider === "github" && <GitHubConnectionRecoveryNotice endpoint={endpoint} />}
|
||||
{activeTab === "settings" && endpoint.provider === "speko" && endpoint.status === "active" && <div className="space-y-6"><SpekoPhoneCallback companyId={endpoint.companyId} endpointId={endpoint.id} /><SpekoPhoneLine companyId={endpoint.companyId} endpointId={endpoint.id} /><SpekoIncomingCalls companyId={endpoint.companyId} endpointId={endpoint.id} agentId={endpoint.assignedAgentId} /></div>}
|
||||
{endpoint.provider === "slack" && <SlackTestMessageButton key={endpoint.id} endpointId={endpoint.id} />}
|
||||
{endpoint.provider === "github" && <div hidden={activeTab !== "settings" && activeTab !== "access"}>
|
||||
|
||||
@@ -263,9 +263,10 @@ describe("GitHub App wizard", () => {
|
||||
vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint);
|
||||
await render("resume=draft-1&reconnect=1");
|
||||
const button = [...container.querySelectorAll("button")].find(
|
||||
(button) => button.textContent === "Reconnect App",
|
||||
(button) => button.getAttribute("aria-label") === "Reconnect saved App",
|
||||
)!;
|
||||
expect(button.disabled).toBe(false);
|
||||
expect(container.querySelector("#github-private-key")).toBeNull();
|
||||
expect(container.querySelector("#github-webhook-secret")).toBeNull();
|
||||
await act(async () => {
|
||||
button.click();
|
||||
@@ -275,6 +276,76 @@ describe("GitHub App wizard", () => {
|
||||
});
|
||||
expect(githubChatApi.connectApp).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps a failed saved-App repair retryable and explains the credential fallback", async () => {
|
||||
fixture.endpoint.status = "attention";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
vi.mocked(chatEndpointsApi.setup).mockRejectedValue(new Error("GitHub could not verify the saved private key"));
|
||||
await render("resume=draft-1&reconnect=1");
|
||||
const button = [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!;
|
||||
await act(async () => button.click());
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not reconnect the saved App");
|
||||
expect(button.disabled).toBe(false);
|
||||
expect(container.querySelector("#github-private-key")).toBeNull();
|
||||
const replace = [...container.querySelectorAll("button")].find(button => button.textContent === "Replace App credentials…")!;
|
||||
await act(async () => replace.click());
|
||||
expect((container.querySelector("#github-app-id") as HTMLInputElement).value).toBe("1234");
|
||||
expect(container.textContent).toContain("Credentials → Key pairs → New key");
|
||||
expect((container.querySelector("#github-private-key") as HTMLInputElement).type).toBe("password");
|
||||
expect([...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Verify and reconnect")?.disabled).toBe(true);
|
||||
expect(githubChatApi.advance).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not reuse an old connected result when fresh verification fails", async () => {
|
||||
fixture.endpoint.status = "attention";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
client.setQueryData(["github-wizard", "draft-1"], { endpointId: "draft-1", state: "connected" });
|
||||
vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint);
|
||||
vi.mocked(githubChatApi.advance).mockRejectedValue(new Error("Delivery verification is unavailable"));
|
||||
await render("resume=draft-1&reconnect=1");
|
||||
await act(async () => [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!.click());
|
||||
expect(container.querySelector("output")?.textContent).toContain("/BOT/apps/chat/connect");
|
||||
expect(container.textContent).toContain("Delivery verification is unavailable");
|
||||
expect([...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")?.disabled).toBe(false);
|
||||
});
|
||||
it.each(["paste", "upload"])("preserves a multiline PEM from %s when replacing credentials", async (source) => {
|
||||
fixture.endpoint.status = "attention";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint);
|
||||
await render("resume=draft-1&reconnect=1");
|
||||
await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "Replace App credentials…")!.click());
|
||||
const key = "-----BEGIN RSA PRIVATE KEY-----\nfixture-only\n-----END RSA PRIVATE KEY-----";
|
||||
if (source === "paste") {
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { getData: () => key } });
|
||||
await act(async () => container.querySelector("#github-private-key")!.dispatchEvent(paste));
|
||||
} else {
|
||||
const upload = container.querySelector('input[type="file"]')!;
|
||||
Object.defineProperty(upload, "files", { value: [{ text: async () => key }] });
|
||||
await act(async () => upload.dispatchEvent(new Event("change", { bubbles: true })));
|
||||
}
|
||||
expect((container.querySelector("#github-private-key") as HTMLInputElement).value).toBe("");
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toContain("Private key loaded");
|
||||
await act(async () => [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Verify and reconnect")!.click());
|
||||
expect(chatEndpointsApi.setup).toHaveBeenCalledWith("draft-1", { action: "reconnect", credentials: { appId: "1234", privateKey: key } });
|
||||
});
|
||||
it("treats typing after loading a PEM as a new key instead of editing a flattened PEM", async () => {
|
||||
fixture.endpoint.status = "attention";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
await render("resume=draft-1&reconnect=1");
|
||||
await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "Replace App credentials…")!.click());
|
||||
const input = container.querySelector("#github-private-key") as HTMLInputElement;
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { getData: () => "-----BEGIN RSA PRIVATE KEY-----\nfixture-only\n-----END RSA PRIVATE KEY-----" } });
|
||||
await act(async () => input.dispatchEvent(paste));
|
||||
expect(input.value).toBe("");
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "replacement");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(input.value).toBe("replacement");
|
||||
expect(container.textContent).not.toContain("Private key loaded");
|
||||
expect([...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Verify and reconnect")?.disabled).toBe(true);
|
||||
expect(chatEndpointsApi.setup).not.toHaveBeenCalled();
|
||||
});
|
||||
async function render(query = "agentId=reviewer") {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useNavigate, useSearchParams, Link } from "@/lib/router";
|
||||
import { buildPermissionsForTrustPreset } from "@/lib/trust-policy-ui";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { GitHubAppRecovery } from "./GitHubConnectionRecovery";
|
||||
import type { GitHubConnectionCompleteLocationState } from "./GitHubConnectionComplete";
|
||||
|
||||
/** Restrict native manifest submission to GitHub registration endpoints. */
|
||||
@@ -92,11 +92,6 @@ export function GitHubChatSetup() {
|
||||
const [submitManifest, setSubmitManifest] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [existing, setExisting] = useState(params.get("reconnect") === "1");
|
||||
const [credentials, setCredentials] = useState({
|
||||
appId: "",
|
||||
privateKey: "",
|
||||
webhookSecret: "",
|
||||
});
|
||||
const [appNotCreated, setAppNotCreated] = useState(false);
|
||||
const [legacyAccount, setLegacyAccount] = useState("");
|
||||
const [identityLinked, setIdentityLinked] = useState(false);
|
||||
@@ -224,8 +219,10 @@ export function GitHubChatSetup() {
|
||||
}
|
||||
}
|
||||
async function refresh() {
|
||||
await current.refetch();
|
||||
if (!identityOnly) await progress.refetch();
|
||||
const verification = !identityOnly ? await progress.refetch() : undefined;
|
||||
const updated = await current.refetch();
|
||||
if (verification?.error) throw verification.error;
|
||||
if (updated.error) throw updated.error;
|
||||
}
|
||||
const appInput = () => ({
|
||||
name: (
|
||||
@@ -241,6 +238,7 @@ export function GitHubChatSetup() {
|
||||
void run(async () => {
|
||||
if (
|
||||
!identityOnly &&
|
||||
!reconnect &&
|
||||
bot &&
|
||||
!bot.botExternalId &&
|
||||
!state?.registration &&
|
||||
@@ -288,9 +286,9 @@ export function GitHubChatSetup() {
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 px-4 py-6 sm:px-6">
|
||||
{!identityOnly && (
|
||||
<SetupWizardNavigation
|
||||
labels={["Choose agent", "Connect GitHub"]}
|
||||
step={bot ? 1 : 0}
|
||||
availableStep={bot ? 1 : 0}
|
||||
labels={reconnect ? ["Reconnect GitHub"] : ["Choose agent", "Connect GitHub"]}
|
||||
step={reconnect ? 0 : bot ? 1 : 0}
|
||||
availableStep={reconnect ? 0 : bot ? 1 : 0}
|
||||
onSelect={() => {}}
|
||||
disabled
|
||||
takeover
|
||||
@@ -301,9 +299,9 @@ export function GitHubChatSetup() {
|
||||
? "Choose agent"
|
||||
: identityOnly || state?.state === "identity"
|
||||
? "Connect your account"
|
||||
: "Connect GitHub"}
|
||||
: reconnect && existing ? "Reconnect GitHub" : "Connect GitHub"}
|
||||
</h1>
|
||||
{(error || (!identityOnly && progress.error) || accounts.error) && (
|
||||
{(error || (!identityOnly && !existing && progress.error) || accounts.error) && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error ||
|
||||
(accounts.error instanceof Error
|
||||
@@ -376,77 +374,27 @@ export function GitHubChatSetup() {
|
||||
{footer()}
|
||||
</>
|
||||
) : existing ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{reconnect
|
||||
? "Leave these blank to reuse this App’s stored credentials, or enter both fields to repair them."
|
||||
: "Use the credentials from this App’s GitHub settings."}
|
||||
</p>
|
||||
<Label htmlFor="github-app-id">App ID</Label>
|
||||
<Input
|
||||
id="github-app-id"
|
||||
value={credentials.appId}
|
||||
onChange={(event) =>
|
||||
setCredentials({ ...credentials, appId: event.target.value })
|
||||
<GitHubAppRecovery
|
||||
key={bot.id}
|
||||
endpoint={bot}
|
||||
reconnect={reconnect}
|
||||
onExit={exit}
|
||||
onConnect={async (credentials) => {
|
||||
if (reconnect || ["active", "paused", "revoked"].includes(bot.status)) {
|
||||
await chatEndpointsApi.setup(bot.id, {
|
||||
action: "reconnect",
|
||||
...(credentials ? { credentials: { appId: credentials.appId.trim(), privateKey: credentials.privateKey } } : {}),
|
||||
});
|
||||
} else if (credentials) {
|
||||
await githubChatApi.connectApp(bot.id, credentials);
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="github-private-key">Private key</Label>
|
||||
<Textarea
|
||||
id="github-private-key"
|
||||
value={credentials.privateKey}
|
||||
onChange={(event) =>
|
||||
setCredentials({ ...credentials, privateKey: event.target.value })
|
||||
}
|
||||
/>
|
||||
{!reconnect &&
|
||||
!["active", "paused", "revoked"].includes(bot.status) && (
|
||||
<>
|
||||
<Label htmlFor="github-webhook-secret">Webhook secret</Label>
|
||||
<Input
|
||||
id="github-webhook-secret"
|
||||
type="password"
|
||||
value={credentials.webhookSecret}
|
||||
onChange={(event) =>
|
||||
setCredentials({
|
||||
...credentials,
|
||||
webhookSecret: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{footer(
|
||||
reconnect ? "Reconnect App" : "Connect existing App",
|
||||
async () => {
|
||||
if (
|
||||
reconnect ||
|
||||
["active", "paused", "revoked"].includes(bot.status)
|
||||
)
|
||||
await chatEndpointsApi.setup(bot.id, {
|
||||
action: "reconnect",
|
||||
...(credentials.appId.trim() || credentials.privateKey.trim()
|
||||
? {
|
||||
credentials: {
|
||||
appId: credentials.appId,
|
||||
privateKey: credentials.privateKey,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
else await githubChatApi.connectApp(bot.id, credentials);
|
||||
setCredentials({ appId: "", privateKey: "", webhookSecret: "" });
|
||||
setExisting(false);
|
||||
await refresh();
|
||||
},
|
||||
reconnect
|
||||
? !!(credentials.appId.trim() || credentials.privateKey.trim()) &&
|
||||
(!credentials.appId.trim() || !credentials.privateKey.trim())
|
||||
: !credentials.appId.trim() ||
|
||||
!credentials.privateKey.trim() ||
|
||||
(!["active", "paused", "revoked"].includes(bot.status) &&
|
||||
!credentials.webhookSecret.trim()),
|
||||
)}
|
||||
</>
|
||||
queryClient.removeQueries({ queryKey: ["github-wizard", bot.id] });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.detail(bot.id) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.list(bot.companyId) });
|
||||
await refresh();
|
||||
setExisting(false);
|
||||
}}
|
||||
/>
|
||||
) : state?.state === "identity" || identityOnly ? (
|
||||
<>
|
||||
{identityOnly && identityLinked ? (
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, CircleHelp, ExternalLink, Loader2 } from "lucide-react";
|
||||
import type { ChatEndpoint } from "@/api/chatEndpoints";
|
||||
import { SetupWizardFooter } from "@/components/SetupWizard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Link } from "@/lib/router";
|
||||
import { gitHubAppSettingsUrl } from "./GitHubAppIdentity";
|
||||
|
||||
export function GitHubConnectionRecoveryNotice({ endpoint }: { endpoint: ChatEndpoint }) {
|
||||
if (!["attention", "revoked"].includes(endpoint.status)) return null;
|
||||
return (
|
||||
<section aria-label="GitHub connection needs repair" className="flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">{endpoint.status === "revoked" ? "GitHub bot is disconnected" : "GitHub connection needs repair"}</h2>
|
||||
<p className="text-sm">Reconnect this bot to restore GitHub reviews and @mentions. Start with the App already saved in Paperclip.</p>
|
||||
</div>
|
||||
<Button asChild><Link to={`/apps/chat/connect?provider=github&resume=${endpoint.id}&reconnect=1`}>Reconnect GitHub</Link></Button>
|
||||
{(endpoint.lastError || endpoint.healthMessage) && <details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">Connection details</summary>
|
||||
<p className="mt-2 break-words">{endpoint.lastError ?? endpoint.healthMessage}</p>
|
||||
</details>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export type GitHubAppCredentials = { appId: string; privateKey: string; webhookSecret: string };
|
||||
|
||||
function CredentialLabel({ id, label, help }: { id: string; label: string; help: string }) {
|
||||
return <div className="flex items-center gap-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Tooltip><TooltipTrigger asChild><button type="button" aria-label={`Help with ${label}`} className="rounded-sm text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<CircleHelp className="size-3.5" aria-hidden="true" />
|
||||
</button></TooltipTrigger><TooltipContent className="max-w-xs">{help}</TooltipContent></Tooltip>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function GitHubAppRecovery({ endpoint, reconnect, onConnect, onExit }: {
|
||||
endpoint: ChatEndpoint;
|
||||
reconnect: boolean;
|
||||
onConnect: (credentials?: GitHubAppCredentials) => Promise<void>;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [replace, setReplace] = useState(!reconnect);
|
||||
const [credentials, setCredentials] = useState<GitHubAppCredentials>({ appId: endpoint.botExternalId ?? "", privateKey: "", webhookSecret: "" });
|
||||
const [privateKeyDraft, setPrivateKeyDraft] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [fileError, setFileError] = useState("");
|
||||
const needsWebhookSecret = !reconnect && !["active", "paused", "revoked"].includes(endpoint.status);
|
||||
const appName = endpoint.botLabel ?? endpoint.setup?.github?.appName ?? "your existing App";
|
||||
const appIdInvalid = !!credentials.appId.trim() && !/^\d+$/.test(credentials.appId.trim());
|
||||
const keyInvalid = !!credentials.privateKey.trim() && !/-----BEGIN (?:RSA )?PRIVATE KEY-----/.test(credentials.privateKey);
|
||||
const keyLoaded = !!credentials.privateKey.trim() && !keyInvalid && !privateKeyDraft;
|
||||
const incomplete = replace && (!credentials.appId.trim() || !credentials.privateKey.trim() || appIdInvalid || keyInvalid || (needsWebhookSecret && !credentials.webhookSecret.trim()));
|
||||
const actionLabel = pending ? "Checking GitHub…" : replace ? (reconnect ? "Verify and reconnect" : "Connect existing App") : "Reconnect saved App";
|
||||
async function connect() {
|
||||
setPending(true);
|
||||
setError("");
|
||||
try { await onConnect(replace ? credentials : undefined); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : "Could not reconnect GitHub. Try again."); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
return <div className="space-y-6">
|
||||
{reconnect && <div className="space-y-3">
|
||||
<p className="text-sm">Restore <strong>{appName}</strong> using its saved credentials. Paperclip will check the App and its installation, then repair event delivery.</p>
|
||||
<p className="text-sm text-muted-foreground">Your agent, repository choices, review settings, and task history stay attached to this connection. You don’t need to create another GitHub App.</p>
|
||||
</div>}
|
||||
{error && <div role="alert" className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-medium">{replace ? "Could not reconnect this App" : "Could not reconnect the saved App"}</p>
|
||||
<p className="break-words">{error}</p>
|
||||
<p>Try again if the service is temporarily unavailable. If GitHub rejected the saved key, replace the credentials for this same App below.</p>
|
||||
</div>}
|
||||
{reconnect && <Button variant="link" className="h-auto p-0" disabled={pending} onClick={() => { setReplace(!replace); setError(""); }}>
|
||||
{replace ? "Use saved credentials instead" : "Replace App credentials…"}
|
||||
</Button>}
|
||||
{replace && <div className="space-y-6">
|
||||
<div className="space-y-2 text-sm">
|
||||
<h2 className="text-base font-semibold">{reconnect ? "Replace credentials for the same App" : "Connect your existing GitHub App"}</h2>
|
||||
<p><a href={gitHubAppSettingsUrl(endpoint)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline">Open GitHub App settings<ExternalLink className="size-3.5" /></a> and choose <strong>{appName}</strong>.</p>
|
||||
<p className="text-muted-foreground">If it’s owned by an organization, open that organization’s Settings → Developer settings → GitHub Apps → Edit. You’ll need permission to manage the App.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CredentialLabel id="github-app-id" label="App ID" help="The numeric App ID on the GitHub App’s settings page. This is different from its Client ID or installation ID." />
|
||||
<p id="github-app-id-help" className="text-sm text-muted-foreground">Copy <strong>App ID</strong> from the App’s settings page. {reconnect && "The saved ID is filled in when available."}</p>
|
||||
<Input id="github-app-id" aria-describedby="github-app-id-help" inputMode="numeric" value={credentials.appId} disabled={pending} aria-invalid={appIdInvalid} onChange={event => setCredentials({ ...credentials, appId: event.target.value })} />
|
||||
{appIdInvalid && <p role="alert" className="text-sm text-destructive">Use the numeric App ID, not the Client ID.</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CredentialLabel id="github-private-key" label="Private key" help="Upload the .pem file downloaded from this GitHub App, or paste its entire contents, including the BEGIN and END lines." />
|
||||
<ol id="github-private-key-help" className="list-decimal space-y-2 pl-5 text-sm text-muted-foreground">
|
||||
<li>In the same App’s settings, open <strong>Credentials → Key pairs → New key</strong>. On the older settings page, use <strong>Private keys → Generate a private key</strong>.</li>
|
||||
<li>Upload the downloaded <strong>.pem</strong> file below, or paste its entire contents.</li>
|
||||
</ol>
|
||||
<p className="text-sm text-muted-foreground">You can reuse a .pem file you already saved. GitHub cannot show an old private key again. Creating a new key for the same App keeps its installation.</p>
|
||||
<Input type="file" accept=".pem" aria-label="Upload private key PEM file" disabled={pending} onChange={async event => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setFileError("");
|
||||
try {
|
||||
const privateKey = await file.text();
|
||||
setCredentials(current => ({ ...current, privateKey }));
|
||||
setPrivateKeyDraft("");
|
||||
} catch { setFileError("Could not read this file. Try again or paste the key below."); }
|
||||
}} />
|
||||
<Input id="github-private-key" type="password" autoComplete="off" placeholder={keyLoaded ? "Paste a replacement PEM key" : "Or paste the complete PEM key"} aria-describedby="github-private-key-help" disabled={pending} value={privateKeyDraft} aria-invalid={keyInvalid}
|
||||
onPaste={event => { event.preventDefault(); setCredentials({ ...credentials, privateKey: event.clipboardData.getData("text") }); setPrivateKeyDraft(""); setFileError(""); }}
|
||||
onChange={event => { setPrivateKeyDraft(event.target.value); setCredentials({ ...credentials, privateKey: event.target.value }); }} />
|
||||
{keyLoaded && <p role="status" className="text-sm">Private key loaded. Paste or upload another key to replace it.</p>}
|
||||
{keyInvalid && <p role="alert" className="text-sm text-destructive">Use a PEM private key, including its BEGIN and END lines. A token or client secret won’t work here.</p>}
|
||||
{fileError && <p role="alert" className="text-sm text-destructive">{fileError}</p>}
|
||||
<a className="inline-flex items-center gap-1 text-sm underline" href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps" target="_blank" rel="noreferrer">GitHub’s private key instructions<ExternalLink className="size-3.5" /></a>
|
||||
</div>
|
||||
{needsWebhookSecret && <div className="space-y-2">
|
||||
<CredentialLabel id="github-webhook-secret" label="Webhook secret" help="The secret configured for this App’s webhook. It must match GitHub exactly." />
|
||||
<p className="text-sm text-muted-foreground">Use the secret saved when you configured this App’s webhook. If you no longer have it, change the webhook secret in GitHub App settings and enter the same value here.</p>
|
||||
<Input id="github-webhook-secret" type="password" autoComplete="off" disabled={pending} value={credentials.webhookSecret} onChange={event => setCredentials({ ...credentials, webhookSecret: event.target.value })} />
|
||||
</div>}
|
||||
</div>}
|
||||
<SetupWizardFooter onSaveExit={onExit} disabled={pending}>
|
||||
<Button aria-label={actionLabel} disabled={pending || incomplete} onClick={() => void connect()}>
|
||||
{pending && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
||||
<span className="sm:hidden" aria-hidden="true">{pending ? "Checking…" : reconnect ? "Reconnect" : "Connect"}</span>
|
||||
<span className="hidden sm:inline" aria-hidden="true">{actionLabel}</span>
|
||||
</Button>
|
||||
</SetupWizardFooter>
|
||||
</div>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { githubChatApi } from "@/api/githubChat";
|
||||
import { chatEndpointsApi } from "@/api/chatEndpoints";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
@@ -50,7 +51,8 @@ const meta = {
|
||||
paperclip: {
|
||||
"capabilities": [
|
||||
"chat-channels/providers",
|
||||
"chat-channels/conversation"
|
||||
"chat-channels/conversation",
|
||||
"chat-channels/lifecycle"
|
||||
],
|
||||
"implementation": "production",
|
||||
"boundary": "fixtures",
|
||||
@@ -61,7 +63,7 @@ const meta = {
|
||||
argTypes: {
|
||||
state: {
|
||||
control: "select",
|
||||
options: ["populated", "empty", "loading", "error", "long", "many", "setup", "skills", "guests", "mentions"],
|
||||
options: ["populated", "empty", "loading", "error", "long", "many", "setup", "skills", "guests", "mentions", "disconnected", "repair-error", "repair-checking"],
|
||||
},
|
||||
},
|
||||
render: ({ state }) => (
|
||||
@@ -333,3 +335,95 @@ export const MentionsOnly: Story = {
|
||||
args: { state: "mentions" },
|
||||
parameters: route("access"),
|
||||
};
|
||||
|
||||
const reconnectRoute = {
|
||||
initialEntries: [`/PAP/apps/chat/connect?provider=github&resume=${endpoint.id}&reconnect=1`],
|
||||
};
|
||||
export const DisconnectedSettings: Story = {
|
||||
name: "04 Recovery / Disconnected settings",
|
||||
args: { state: "disconnected" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("heading", { name: "GitHub connection needs repair" })).toBeVisible();
|
||||
await expect(canvas.getByRole("link", { name: "Reconnect GitHub" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const ReconnectSavedApp: Story = {
|
||||
name: "04 Recovery / Reconnect saved App",
|
||||
args: { state: "disconnected" },
|
||||
parameters: reconnectRoute,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("button", { name: "Reconnect saved App" })).toBeEnabled();
|
||||
await expect(canvas.queryByLabelText("Private key", { exact: true })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const ReplaceAppCredentials: Story = {
|
||||
name: "04 Recovery / Find App ID and private key",
|
||||
args: { state: "disconnected" },
|
||||
parameters: reconnectRoute,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Replace App credentials…" }));
|
||||
await expect(canvas.getByLabelText("App ID", { exact: true })).toHaveValue("123456");
|
||||
await expect(canvas.getByRole("link", { name: "Open GitHub App settings" })).toHaveAttribute("href", "https://github.com/organizations/acme/settings/apps/maya-reviews");
|
||||
await expect(canvas.getByRole("button", { name: "Verify and reconnect" })).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const ReconnectFailed: Story = {
|
||||
name: "04 Recovery / Saved key rejected",
|
||||
args: { state: "repair-error" },
|
||||
parameters: reconnectRoute,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Reconnect saved App" }));
|
||||
await expect(await canvas.findByRole("alert")).toHaveTextContent("Could not reconnect the saved App");
|
||||
await expect(canvas.getByRole("button", { name: "Reconnect saved App" })).toBeEnabled();
|
||||
},
|
||||
};
|
||||
export const PrivateKeyLoaded: Story = {
|
||||
name: "04 Recovery / Private key loaded",
|
||||
args: { state: "disconnected" },
|
||||
parameters: reconnectRoute,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Replace App credentials…" }));
|
||||
await userEvent.upload(canvas.getByLabelText("Upload private key PEM file"), new File([
|
||||
"-----BEGIN RSA PRIVATE KEY-----\nfixture-only\n-----END RSA PRIVATE KEY-----",
|
||||
], "storybook-fixture.pem", { type: "application/x-pem-file" }));
|
||||
await expect(await canvas.findByText("Private key loaded. Paste or upload another key to replace it.")).toBeVisible();
|
||||
await expect(canvas.getByLabelText("Private key", { exact: true })).toHaveValue("");
|
||||
await expect(canvas.getByRole("button", { name: "Verify and reconnect" })).toBeEnabled();
|
||||
},
|
||||
};
|
||||
export const ReconnectChecking: Story = {
|
||||
name: "04 Recovery / Checking saved App",
|
||||
args: { state: "repair-checking" },
|
||||
parameters: reconnectRoute,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Reconnect saved App" }));
|
||||
await expect(canvas.getByRole("button", { name: "Checking GitHub…" })).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const Reconnected: Story = {
|
||||
name: "04 Recovery / Restored connection",
|
||||
args: { state: "disconnected" },
|
||||
parameters: reconnectRoute,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Reconnect saved App" }));
|
||||
await expect(await within(document.body).findByRole("dialog")).toBeVisible();
|
||||
await expect(canvas.queryByRole("heading", { name: "GitHub connection needs repair" })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const RecoveryMobile: Story = {
|
||||
...ReplaceAppCredentials,
|
||||
name: "04 Recovery / Mobile credential instructions",
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
export const RecoveryLight: Story = {
|
||||
...DisconnectedSettings,
|
||||
name: "04 Recovery / Light disconnected settings",
|
||||
globals: { theme: "light" },
|
||||
};
|
||||
@@ -14,7 +14,16 @@ import {
|
||||
members,
|
||||
} from "./fixtures";
|
||||
|
||||
export type FixtureState = "populated" | "empty" | "loading" | "error" | "long" | "many" | "setup" | "skills" | "guests" | "mentions";
|
||||
export type FixtureState = "populated" | "empty" | "loading" | "error" | "long" | "many" | "setup" | "skills" | "guests" | "mentions" | "disconnected" | "repair-error" | "repair-checking";
|
||||
function initialEndpoint(state: FixtureState) {
|
||||
if (state === "setup") return { ...endpoint, status: "draft" as const, setup: { github: { stage: "setup" as const } } };
|
||||
if (["disconnected", "repair-error", "repair-checking"].includes(state)) return {
|
||||
...endpoint, status: "attention" as const,
|
||||
healthMessage: "GitHub connection needs attention",
|
||||
lastError: "GitHub failure has no request binding",
|
||||
};
|
||||
return structuredClone(endpoint);
|
||||
}
|
||||
const reviewSkill = {
|
||||
id: "11111111-1111-4111-8111-111111111111", slug: "code-review", name: "Code review",
|
||||
key: "company/company-storybook/code-review", description: "Follow the team's review playbook.",
|
||||
@@ -31,10 +40,9 @@ export function FixtureApi({
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
client.setQueryData(queryKeys.chatEndpoints.detail(endpoint.id), endpoint);
|
||||
client.setQueryData(queryKeys.chatEndpoints.detail(endpoint.id), initialEndpoint(state));
|
||||
client.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||||
client.setQueryData(["github-setup", endpoint.id], state === "setup"
|
||||
? { ...endpoint, status: "draft", setup: { github: { stage: "setup" } } } : endpoint);
|
||||
client.setQueryData(["github-setup", endpoint.id], initialEndpoint(state));
|
||||
client.setQueryData(["github-wizard", endpoint.id], { endpointId: endpoint.id, state: state === "setup" ? "create" : "connected" });
|
||||
client.setQueryData(queryKeys.chatEndpoints.list(endpoint.companyId), [endpoint]);
|
||||
client.setQueryData(queryKeys.projects.list(endpoint.companyId), []);
|
||||
@@ -53,9 +61,7 @@ export function FixtureApi({
|
||||
const [ready, setReady] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const original = window.fetch;
|
||||
let currentEndpoint = state === "setup"
|
||||
? { ...endpoint, status: "draft", setup: { github: { stage: "setup" } } }
|
||||
: structuredClone(endpoint);
|
||||
let currentEndpoint = initialEndpoint(state);
|
||||
let saved = { revision: 2, configuration: structuredClone(configuration) };
|
||||
if (state === "skills") saved.configuration.defaults = {
|
||||
...saved.configuration.defaults,
|
||||
@@ -98,7 +104,19 @@ export function FixtureApi({
|
||||
if (path === `/api/chat-endpoints/${endpoint.id}` && init?.method === "PATCH") {
|
||||
currentEndpoint = { ...currentEndpoint, ...body };
|
||||
}
|
||||
if (path.endsWith("/github/setup")) return Response.json({ endpointId: endpoint.id, state: state === "setup" ? "create" : "connected" });
|
||||
if (path === `/api/chat-endpoints/${endpoint.id}/setup` && init?.method === "POST") {
|
||||
if (body.action !== "reconnect") return Response.json({ error: "Unexpected setup action" }, { status: 400 });
|
||||
if (state === "repair-error") return Response.json({ error: "GitHub could not verify the saved private key. Check the App’s keys on GitHub." }, { status: 422 });
|
||||
if (state === "repair-checking") return new Promise<Response>(() => {});
|
||||
currentEndpoint = { ...currentEndpoint, status: "verifying", lastError: null, healthMessage: "Checking GitHub delivery", setup: { ...currentEndpoint.setup, step: "test" } };
|
||||
return Response.json(currentEndpoint);
|
||||
}
|
||||
if (path.endsWith("/github/setup")) {
|
||||
if (state === "setup") return Response.json({ endpointId: endpoint.id, state: "create" });
|
||||
if (currentEndpoint.status === "attention") return Response.json({ endpointId: endpoint.id, state: "recovery", message: "Reconnect the saved App to repair delivery." });
|
||||
currentEndpoint = { ...currentEndpoint, status: "active", healthMessage: "GitHub delivery verified", setup: { ...currentEndpoint.setup, step: "complete" } };
|
||||
return Response.json({ endpointId: endpoint.id, state: "connected" });
|
||||
}
|
||||
if (path.endsWith("/repositories/access")) {
|
||||
repos = repos.map((r) => (!body.enabled || r.availability === "available") ? { ...r, enabled: body.enabled } : r);
|
||||
return Response.json({ success: true });
|
||||
|
||||
@@ -22,14 +22,18 @@ export const endpoint: ChatEndpoint = {
|
||||
assignedAgentName: agent.name,
|
||||
connectionId: "story-github-tools",
|
||||
botLabel: "Maya Reviews",
|
||||
botExternalId: "123456",
|
||||
botUsername: "maya-reviews[bot]",
|
||||
providerAccountLabel: "acme",
|
||||
allowUnlinkedPeople: false,
|
||||
setup: {
|
||||
step: "complete",
|
||||
webhookVerifiedAt: "2026-10-01T12:00:00.000Z",
|
||||
github: {
|
||||
stage: "verify",
|
||||
appSlug: "maya-reviews",
|
||||
appOwnerType: "organization",
|
||||
appOwnerLogin: "acme",
|
||||
ownerType: "organization",
|
||||
ownerLogin: "acme",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user