From c0bdc00b87d6b79a95be6bf2cb79aecaf84ebc51 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 18 Jun 2026 11:58:38 -0700 Subject: [PATCH] fix(site): set external auth provider polling status individually (#26313) fixes #22420 ref DEVEX-369 ref DEVEX-269 The bug on `CreateWorkspacePage`, where clicking one external auth provider login button disabled all providers' login buttons, was caused by providers all sharing a single polling status (`"idle" | "polling" | "abandoned"`) in the `useExternalAuth` hook. ## changes - Instead of setting one status across all providers, the polling status in `useExternalAuth` is now tracked for each provider in a record whose keys are the providers' IDs. - The biggest diff is a new Storybook file CreateWorkspacePage.stories.tsx which reproduces the bug behavior from the issue. - Until now we've only had CreateWorkspacePageView.stories.tsx, which isn't able to model the user interactions / API responses needed to verify the bugfix. This file is unchanged. - Also deletes `CreateWorkspacePage`'s `useExternalAuth` hook in favor of the global `useExternalAuth` hook (see #26310) (co-written with Coder Agents) --- site/src/hooks/useExternalAuth.ts | 60 +++-- .../tasks/TaskPrompt/TaskPrompt.stories.tsx | 34 +++ .../modules/tasks/TaskPrompt/TaskPrompt.tsx | 16 +- .../CreateWorkspacePage.stories.tsx | 220 ++++++++++++++++++ .../CreateWorkspacePage.tsx | 47 +--- .../CreateWorkspacePageView.stories.tsx | 2 +- .../CreateWorkspacePageView.tsx | 12 +- site/src/testHelpers/entities.ts | 10 + 8 files changed, 323 insertions(+), 78 deletions(-) create mode 100644 site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts index 6c0db550d89..81dcae5de06 100644 --- a/site/src/hooks/useExternalAuth.ts +++ b/site/src/hooks/useExternalAuth.ts @@ -5,13 +5,16 @@ import { templateVersionExternalAuth } from "#/api/queries/templates"; export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; export const useExternalAuth = (versionId: string | undefined) => { - const [externalAuthPollingState, setExternalAuthPollingState] = - useState("idle"); + const [pollingState, setPollingState] = useState< + Record + >({}); - const startPollingExternalAuth = useCallback(() => { - setExternalAuthPollingState("polling"); + const startPollingExternalAuth = useCallback((providerId: string) => { + setPollingState((prev) => ({ ...prev, [providerId]: "polling" })); }, []); + const isAnyPolling = Object.values(pollingState).some((s) => s === "polling"); + const { data: externalAuth, isPending: isLoadingExternalAuth, @@ -19,37 +22,58 @@ export const useExternalAuth = (versionId: string | undefined) => { } = useQuery({ ...templateVersionExternalAuth(versionId ?? ""), enabled: Boolean(versionId), - refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, + refetchInterval: isAnyPolling ? 1000 : false, }); - const allSignedIn = externalAuth?.every((it) => it.authenticated); - + // Stop polling individual providers once they authenticate. useEffect(() => { - if (allSignedIn) { - setExternalAuthPollingState("idle"); + if (!externalAuth) { return; } + setPollingState((prev) => { + let changed = false; + const next = { ...prev }; + for (const auth of externalAuth) { + if (auth.authenticated && next[auth.id] === "polling") { + next[auth.id] = "idle"; + changed = true; + } + } + return changed ? next : prev; + }); + }, [externalAuth]); - if (externalAuthPollingState !== "polling") { + // Per-provider 60-second timeout. + useEffect(() => { + const pollingIds = Object.entries(pollingState) + .filter(([, authPollingState]) => authPollingState === "polling") + .map(([id]) => id); + + if (pollingIds.length === 0) { return; } - // Poll for a maximum of one minute - const quitPolling = setTimeout( - () => setExternalAuthPollingState("abandoned"), - 60_000, + const timers = pollingIds.map((id) => + setTimeout(() => { + setPollingState((prev) => + prev[id] === "polling" ? { ...prev, [id]: "abandoned" } : prev, + ); + }, 60_000), ); + return () => { - clearTimeout(quitPolling); + for (const t of timers) { + clearTimeout(t); + } }; - }, [externalAuthPollingState, allSignedIn]); + }, [pollingState]); return { startPollingExternalAuth, externalAuth, - externalAuthPollingState, + externalAuthPollingState: pollingState, isLoadingExternalAuth, externalAuthError: error, - isPollingExternalAuth: externalAuthPollingState === "polling", + isPollingExternalAuth: isAnyPolling, }; }; diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx index 43e0b6083f4..809c9256ebd 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx @@ -10,6 +10,7 @@ import { MockTasks, MockTemplate, MockTemplateVersion, + MockTemplateVersionExternalAuthAzure, MockTemplateVersionExternalAuthGithub, MockTemplateVersionExternalAuthGithubAuthenticated, MockUserOwner, @@ -387,6 +388,39 @@ export const MissingExternalAuth: Story = { }, }; +export const MissingExternalAuthMultipleProviders: Story = { + beforeEach: () => { + spyOn(API, "getTasks") + .mockResolvedValueOnce(MockTasks) + .mockResolvedValue([MockNewTaskData, ...MockTasks]); + spyOn(API, "createTask").mockResolvedValue(MockTask); + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthAzure, + ]); + // Prevent the auth button from actually opening a popup. + spyOn(window, "open").mockReturnValue(null); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + const githubButton = await canvas.findByRole("button", { + name: /connect to github/i, + }); + const azureButton = await canvas.findByRole("button", { + name: /connect to azure/i, + }); + + await step("Click GitHub auth button", async () => { + await userEvent.click(githubButton); + }); + + await step("Azure button remains enabled", () => { + expect(azureButton).toBeEnabled(); + }); + }, +}; + export const ExternalAuthError: Story = { beforeEach: () => { spyOn(API, "getTasks") diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 8468f755816..21481e6c2d4 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -436,14 +436,14 @@ const ExternalAuthButtons: FC = ({ versionId, missedExternalAuth, }) => { - const { - startPollingExternalAuth, - isPollingExternalAuth, - externalAuthPollingState, - } = useExternalAuth(versionId); - const shouldRetry = externalAuthPollingState === "abandoned"; + const { startPollingExternalAuth, externalAuthPollingState } = + useExternalAuth(versionId); return missedExternalAuth.map((auth) => { + const isPollingExternalAuth = + externalAuthPollingState[auth.id] === "polling"; + const shouldRetry = externalAuthPollingState[auth.id] === "abandoned"; + return (
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index d5ea25ae39c..71e5d38f3f6 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3518,6 +3518,16 @@ export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.Templa display_name: "GitHub", }; +export const MockTemplateVersionExternalAuthAzure: TypesGen.TemplateVersionExternalAuth = + { + id: "azure", + type: "azure", + authenticate_url: "https://example.com/external-auth/azure", + authenticated: false, + display_icon: "/icon/azure.svg", + display_name: "Azure", + }; + export const MockDeploymentStats: TypesGen.DeploymentStats = { aggregated_from: "2023-03-06T19:08:55.211625Z", collected_at: "2023-03-06T19:12:55.211625Z",