fix(connections): preserve wizard recovery and repository restrictions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 00:22:55 -05:00
1 parent cf8578a9dd
commit 87945c8cf3
15 files changed
+750 -189

No files matched your search

@@ -37,6 +37,7 @@ vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: mocks }));
vi.mock("@/api/githubChat", () => ({ githubChatApi: {
configuration: async () => ({ revision: 0, configuration: {} }),
progress: async () => mocks.get(),
advance: async () => ({ endpointId: "endpoint-a", state: "create" }),
} }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: "owner-user", name: "Owner" } }) } }));
vi.mock("@/api/health", () => ({ healthApi: { get: async () => ({ deploymentMode: "authenticated" }) } }));
@@ -587,13 +588,13 @@ describe("chat setup and identity-link clipboard actions", () => {
expect(container.querySelector("h1")?.textContent).toBe("Add Slack credentials");
});
it.each(["slack", "github", "discord", "telegram", "microsoft-teams"] as const)(
it.each(["slack", "discord", "telegram", "microsoft-teams"] as const)(
"lets %s setup revisit the agent without duplicating the connection or losing provider fields",
async (provider) => {
await render(provider);
const nav = container.querySelector(provider === "github" ? 'nav[aria-label="Setup progress"]' : 'aside nav[aria-label="Connection setup progress"]')!;
const nav = container.querySelector('aside nav[aria-label="Connection setup progress"]')!;
expect(nav).not.toBeNull();
if (provider !== "github") expect(container.querySelector("main nav")).toBeNull();
expect(container.querySelector("main nav")).toBeNull();
const steps = [...nav.querySelectorAll("button")];
expect(steps[1].getAttribute("aria-current")).toBe("step");
expect(steps[2].disabled).toBe(true);
@@ -607,55 +608,30 @@ describe("chat setup and identity-link clipboard actions", () => {
expect([...container.querySelectorAll("main input")]).toEqual(fields);
expect(mocks.create).not.toHaveBeenCalled();
await click("1Choose agent");
await click(provider === "slack" ? "2Create Slack app" : provider === "github" ? "2Connect GitHub App" : "2Connect provider");
await click(provider === "slack" ? "2Create Slack app" : "2Connect provider");
expect(steps[1].getAttribute("aria-current")).toBe("step");
},
);
it("copies the one-time webhook secret through the same fallback", async () => {
it("resumes an assigned GitHub draft in the two-screen wizard without creating a connection", async () => {
await render("github");
await click("Use an existing App");
await click("Generate webhook secret");
await click("Copy webhook secret");
expect(copied).toEqual([secret]);
expect(writeText).not.toHaveBeenCalled();
const steps = [...container.querySelectorAll('nav[aria-label="Setup progress"] button')];
expect(steps).toHaveLength(2);
expect(steps[1].getAttribute("aria-current")).toBe("step");
expect(container.querySelector("h1")?.textContent).toBe("Connect GitHub");
expect(container.querySelector("input#github-app-name")?.getAttribute("value")).toBe("Maya");
expect(mocks.create).not.toHaveBeenCalled();
});
it("reports a failed secret copy without exposing the secret or an unhandled rejection", async () => {
it("keeps manual existing-App recovery write-only without generating or copying credentials", async () => {
await render("github");
await click("Use an existing App");
await click("Generate webhook secret");
execCommand.mockReturnValue(false);
await click("Copy webhook secret");
expect(mocks.pushToast).toHaveBeenLastCalledWith({
title: "Couldn't copy to clipboard",
body: "Select and copy the value manually.",
tone: "error",
});
expect(JSON.stringify(mocks.pushToast.mock.calls)).not.toContain(secret);
});
// The toast provider drops an identical toast raised inside 3.5 seconds, so
// a reader clicking twice against a blocked clipboard would see the failure
// once and then nothing. The inline state has to answer every click.
it("still shows a repeated copy failure the toast would have deduplicated", async () => {
await render("github");
await click("Use an existing App");
await click("Generate webhook secret");
execCommand.mockReturnValue(false);
await click("Copy webhook secret");
expect(container.textContent).toContain("Couldn’t copy");
// Same failure again, well inside the dedupe window: the second toast is
// suppressed, so the button itself is the only thing left to say so.
const toastsAfterFirst = mocks.pushToast.mock.calls.length;
await click("Couldn’t copy — select it manually");
expect(container.textContent).toContain("Couldn’t copy");
expect(container.textContent).not.toContain("Webhook secret copied");
expect(mocks.pushToast.mock.calls.length).toBeGreaterThanOrEqual(
toastsAfterFirst,
);
await click("I already have an App");
expect(container.querySelector('input[type="password"]')).not.toBeNull();
expect(container.querySelector("textarea")?.value).toBe("");
expect(container.textContent).not.toContain("Copy webhook secret");
expect(container.textContent).not.toContain("Generate webhook secret");
expect(mocks.generateSetupSecret).not.toHaveBeenCalled();
expect(copied).toEqual([]);
});
it("copies the private identity link with fallback and preserves success feedback", async () => {
@@ -61,7 +61,12 @@ vi.mock("@/api/agents", () => ({
agentsApi: { list: vi.fn(), get: vi.fn(), updatePermissions: vi.fn() },
}));
vi.mock("@/api/chatEndpoints", () => ({
chatEndpointsApi: { create: vi.fn(), get: vi.fn(), listResources: vi.fn() },
chatEndpointsApi: {
create: vi.fn(),
get: vi.fn(),
listResources: vi.fn(),
setup: vi.fn(),
},
}));
vi.mock("@/api/githubChat", () => ({
githubChatApi: {
@@ -173,6 +178,24 @@ describe("GitHub App wizard", () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
}
it("reconnects the same App using vaulted credentials without requiring another paste", async () => {
fixture.endpoint.status = "attention";
fixture.endpoint.botExternalId = "1234";
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",
)!;
expect(button.disabled).toBe(false);
expect(container.querySelector("#github-webhook-secret")).toBeNull();
await act(async () => {
button.click();
});
expect(chatEndpointsApi.setup).toHaveBeenCalledWith("draft-1", {
action: "reconnect",
});
expect(githubChatApi.connectApp).not.toHaveBeenCalled();
});
async function render(query = "agentId=reviewer") {
await act(async () =>
root.render(
+44 -30
View File
@@ -54,6 +54,7 @@ export function GitHubChatSetup() {
const queryClient = useQueryClient();
const resume = params.get("resume");
const identityOnly = params.get("stage") === "identity";
const reconnect = params.get("reconnect") === "1";
const [agentId, setAgentId] = useState(params.get("agentId") ?? "");
const [ownerType, setOwnerType] = useState<"personal" | "organization">(
"personal",
@@ -106,7 +107,7 @@ export function GitHubChatSetup() {
(agent) => agent.id === (bot?.assignedAgentId ?? agentId),
);
const state = progress.data;
const connected = state?.state === "connected";
const connected = state?.state === "connected" && !existing;
useEffect(() => {
setBreadcrumbs([
{ label: "Connectors", href: "/apps" },
@@ -211,7 +212,7 @@ export function GitHubChatSetup() {
step={bot ? 1 : 0}
availableStep={bot ? 1 : 0}
onSelect={() => {}}
disabled={busy}
disabled
takeover
/>
)}
@@ -220,7 +221,7 @@ export function GitHubChatSetup() {
? "Choose agent"
: connected
? "GitHub connected"
: identityOnly
: identityOnly || state?.state === "identity"
? "Connect your account"
: "Connect GitHub"}
</h1>
@@ -333,7 +334,9 @@ export function GitHubChatSetup() {
) : existing ? (
<>
<p className="text-sm text-muted-foreground">
Use the credentials from this App’s GitHub settings.
{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
@@ -351,42 +354,53 @@ export function GitHubChatSetup() {
setCredentials({ ...credentials, privateKey: event.target.value })
}
/>
{!["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,
})
}
/>
</>
)}
{!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(
"Connect existing App",
reconnect ? "Reconnect App" : "Connect existing App",
async () => {
if (["active", "paused", "revoked"].includes(bot.status))
if (
reconnect ||
["active", "paused", "revoked"].includes(bot.status)
)
await chatEndpointsApi.setup(bot.id, {
action: "reconnect",
credentials: {
appId: credentials.appId,
privateKey: credentials.privateKey,
},
...(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();
},
!credentials.appId.trim() ||
!credentials.privateKey.trim() ||
(!["active", "paused", "revoked"].includes(bot.status) &&
!credentials.webhookSecret.trim()),
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()),
)}
</>
) : state?.state === "identity" || identityOnly ? (