Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 62 additions & 28 deletions packages/plugins-ui/src/plugin-connect-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,20 +196,39 @@ export function PluginConnectPanel({
readonly onChanged: () => void;
}) {
const open = plugin !== null;
const [oauthConfigured, setOauthConfigured] = useState<
Record<string, boolean>
>({});
// 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<Record<string, boolean>>;
}
| { 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 (
<Dialog
Expand Down Expand Up @@ -253,28 +272,43 @@ export function PluginConnectPanel({
</a>
</Button>
) : plugin.descriptor.oauth !== undefined ? (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
This workbench isn&apos;t set up with the one-click GitHub
app, so connect with a token instead. Create a token with{" "}
<code className="text-xs">repo</code> scope at{" "}
<a
className="underline"
href={plugin.descriptor.docsUrl}
target="_blank"
rel="noreferrer"
oauthProbe.status === "error" ? (
<div className="flex flex-col gap-3">
<p className="text-sm text-destructive" role="alert">
Couldn&apos;t check whether one-click connect is available.
</p>
<Button
type="button"
variant="outline"
onClick={() => setOauthProbeKey((value) => value + 1)}
>
github.com/settings/tokens
</a>{" "}
and paste it below.
</p>
<ApiKeyConnectForm
tenantId={tenantId}
connectorId={plugin.descriptor.id}
displayName={plugin.descriptor.displayName}
onConnected={onChanged}
/>
</div>
Try again
</Button>
</div>
) : oauthProbe.status === "loading" ? null : (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
This workbench isn&apos;t set up with the one-click GitHub
app, so connect with a token instead. Create a token with{" "}
<code className="text-xs">repo</code> scope at{" "}
<a
className="underline"
href={plugin.descriptor.docsUrl}
target="_blank"
rel="noreferrer"
>
github.com/settings/tokens
</a>{" "}
and paste it below.
</p>
<ApiKeyConnectForm
tenantId={tenantId}
connectorId={plugin.descriptor.id}
displayName={plugin.descriptor.displayName}
onConnected={onChanged}
/>
</div>
)
) : plugin.descriptor.credentialInputKind === "url" ? (
<ApiKeyConnectForm
tenantId={tenantId}
Expand Down
48 changes: 48 additions & 0 deletions packages/plugins-ui/test/plugin-connect-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,54 @@ describe("PluginConnectPanel", () => {
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();
Expand Down
Loading