diff --git a/packages/plugins-ui/src/plugin-connect-panel.tsx b/packages/plugins-ui/src/plugin-connect-panel.tsx index 68320ab20..110be37e6 100644 --- a/packages/plugins-ui/src/plugin-connect-panel.tsx +++ b/packages/plugins-ui/src/plugin-connect-panel.tsx @@ -196,20 +196,39 @@ export function PluginConnectPanel({ readonly onChanged: () => void; }) { const open = plugin !== null; - const [oauthConfigured, setOauthConfigured] = useState< - Record - >({}); + // CL-6830: probe is tri-state — never fold a failure into `{}`, which + // reads as "hosted app absent" and hides one-click connect behind the + // not-configured token paste. + const [oauthProbe, setOauthProbe] = useState< + | { readonly status: "loading" } + | { + readonly status: "ready"; + readonly configured: Readonly>; + } + | { readonly status: "error" } + >({ status: "loading" }); + const [oauthProbeKey, setOauthProbeKey] = useState(0); useEffect(() => { if (!open) return; + let cancelled = false; + setOauthProbe({ status: "loading" }); fetchOAuthConfigured(tenantId) - .then(setOauthConfigured) - .catch(() => setOauthConfigured({})); - }, [open, tenantId]); + .then((configured) => { + if (!cancelled) setOauthProbe({ status: "ready", configured }); + }) + .catch(() => { + if (!cancelled) setOauthProbe({ status: "error" }); + }); + return () => { + cancelled = true; + }; + }, [open, tenantId, oauthProbeKey]); const hostedAppAvailable = plugin?.descriptor.oauth !== undefined && - oauthConfigured[plugin.descriptor.id] === true; + oauthProbe.status === "ready" && + oauthProbe.configured[plugin.descriptor.id] === true; return ( ) : plugin.descriptor.oauth !== undefined ? ( -
-

- This workbench isn't set up with the one-click GitHub - app, so connect with a token instead. Create a token with{" "} - repo scope at{" "} - +

+ Couldn't check whether one-click connect is available. +

+
+ Try again + + + ) : oauthProbe.status === "loading" ? null : ( +
+

+ This workbench isn't set up with the one-click GitHub + app, so connect with a token instead. Create a token with{" "} + repo scope at{" "} + + github.com/settings/tokens + {" "} + and paste it below. +

+ +
+ ) ) : plugin.descriptor.credentialInputKind === "url" ? ( { expect(container.querySelector('input[type="password"]')).not.toBeNull(); }); + // CL-6830: a failed oauth-configured probe must not collapse into `{}` + // (which reads as "hosted app absent" and hides one-click connect). + test("GitHub when the oauth-configured probe fails shows error and retry, not the not-configured token paste", async () => { + globalThis.fetch = (() => + Promise.reject(new Error("network down"))) as unknown as typeof fetch; + + const container = render(notConnected(githubDescriptor())); + await settle(); + + expect(container.textContent).toContain("Couldn't check"); + expect(container.textContent).toContain("Try again"); + expect(container.textContent).not.toContain( + "This workbench isn't set up with the one-click GitHub app", + ); + expect(container.querySelector('input[type="password"]')).toBeNull(); + }); + + test("retrying after an oauth-configured probe failure can reveal hosted connect", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + if (calls === 1) { + return Promise.reject(new Error("network down")); + } + return new Response(JSON.stringify({ github: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const container = render(notConnected(githubDescriptor())); + await settle(); + + const retry = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Try again") === true, + ); + expect(retry).not.toBeUndefined(); + + act(() => { + retry?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await settle(); + + const link = container.querySelector("a"); + expect(link?.textContent).toContain("Connect with GitHub"); + expect(container.querySelector('input[type="password"]')).toBeNull(); + }); + test("nothing renders when no plugin is selected", () => { const container = render(null); expect(container.querySelector('input[type="password"]')).toBeNull();