From fb9d191417395472a6c6bd4be5296e60b1ddca22 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 10 Jun 2026 21:47:30 +0000 Subject: [PATCH 01/10] test(site/CreateWorkspacePage): add container stories for multi-provider external auth Adds CreateWorkspacePage.stories.tsx with four stories covering the multi-provider external auth bug described in #22420: - MultipleExternalAuth: both buttons render enabled - ClickingOneAuthDoesNotDisableOthers: regression test for #22420 - OneProviderAuthenticated: mixed authenticated/unauthenticated state - SequentialAuthFlow: full polling flow with mockResolvedValueOnce --- .../CreateWorkspacePage.stories.tsx | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx new file mode 100644 index 00000000000..b7b8969afea --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -0,0 +1,234 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { API } from "#/api/api"; +import type { TemplateVersionExternalAuth } from "#/api/typesGenerated"; +import { + MockTemplate, + MockTemplateVersion, + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthGithubAuthenticated, + MockUserOwner, +} from "#/testHelpers/entities"; +import { withAuthProvider, withDashboardProvider } from "#/testHelpers/storybook"; +import CreateWorkspacePage from "./CreateWorkspacePage"; + +const MockGitLabExternalAuth: TemplateVersionExternalAuth = { + id: "gitlab", + type: "gitlab", + authenticate_url: "https://example.com/external-auth/gitlab", + authenticated: false, + display_icon: "/icon/gitlab.svg", + display_name: "GitLab", +}; + +const MockGitLabExternalAuthAuthenticated: TemplateVersionExternalAuth = { + ...MockGitLabExternalAuth, + authenticated: true, +}; + +/** + * Mocks API.templateVersionDynamicParameters to immediately send an empty + * DynamicParametersResponse so the page renders the form instead of the + * loader. + */ +function mockDynamicParameters() { + spyOn(API, "templateVersionDynamicParameters").mockImplementation( + (_versionId, _ownerId, callbacks) => { + // Fire asynchronously so the component mounts before the message + // arrives, matching real WebSocket behavior. + setTimeout(() => { + callbacks.onMessage({ id: 0, parameters: [], diagnostics: [] }); + }, 0); + + return { close: () => {} } as unknown as WebSocket; + }, + ); +} + +const meta: Meta = { + title: "pages/CreateWorkspacePage", + component: CreateWorkspacePage, + decorators: [withAuthProvider, withDashboardProvider], + parameters: { + layout: "fullscreen", + user: MockUserOwner, + reactRouter: reactRouterParameters({ + location: { + pathParams: { + organization: MockTemplate.organization_name, + template: MockTemplate.name, + }, + }, + routing: { + path: "/templates/:organization/:template/workspace", + }, + }), + }, + beforeEach: () => { + // Prevent the auth button from actually opening a popup. + spyOn(window, "open").mockReturnValue(null); + + // Template, version, and preset queries. + spyOn(API, "getTemplateByName").mockResolvedValue(MockTemplate); + spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); + spyOn(API, "getTemplateVersionPresets").mockResolvedValue(null); + spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForAny: true, + canUpdateTemplate: false, + }); + + // Dynamic parameters over WebSocket. + mockDynamicParameters(); + + // Default: no external auth required. + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * Renders two unauthenticated external auth providers. Both "Login with" + * buttons should be visible and enabled. + */ +export const MultipleExternalAuth: Story = { + beforeEach: () => { + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithub, + MockGitLabExternalAuth, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const githubButton = await canvas.findByRole("button", { + name: /login with github/i, + }); + const gitlabButton = await canvas.findByRole("button", { + name: /login with gitlab/i, + }); + + expect(githubButton).toBeEnabled(); + expect(gitlabButton).toBeEnabled(); + }, +}; + +/** + * Clicking one external auth button should only show a loading spinner on + * that button. The other provider's button must remain enabled so the user + * can authenticate with both without a page refresh. + * + * This is the regression test for coder/coder#22420. + */ +export const ClickingOneAuthDoesNotDisableOthers: Story = { + beforeEach: () => { + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithub, + MockGitLabExternalAuth, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + const githubButton = await canvas.findByRole("button", { + name: /login with github/i, + }); + const gitlabButton = await canvas.findByRole("button", { + name: /login with gitlab/i, + }); + + await step("Click GitHub auth button", async () => { + await userEvent.click(githubButton); + }); + + await step("GitLab button remains enabled", async () => { + // After the fix, each provider tracks its own polling state so + // only the clicked provider shows a loading spinner. + await waitFor(() => { + expect(gitlabButton).toBeEnabled(); + }); + }); + }, +}; + +/** + * After the first provider completes authentication and the API starts + * returning it as authenticated, its button should be replaced with the + * "Authenticated" badge. The second provider's button should still be + * clickable. + */ +export const OneProviderAuthenticated: Story = { + beforeEach: () => { + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithubAuthenticated, + MockGitLabExternalAuth, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("GitHub shows authenticated", async () => { + await canvas.findByText("Authenticated"); + }); + + await step("GitLab login button is still enabled", async () => { + const gitlabButton = await canvas.findByRole("button", { + name: /login with gitlab/i, + }); + expect(gitlabButton).toBeEnabled(); + }); + }, +}; + +/** + * Simulates the full two-provider authentication flow: click the first + * provider, have polling return it as authenticated, then click the second + * provider. + */ +export const SequentialAuthFlow: Story = { + beforeEach: () => { + // First call: both unauthenticated. + // Subsequent calls: GitHub authenticated (simulating a successful login + // during the polling interval). + spyOn(API, "getTemplateVersionExternalAuth") + .mockResolvedValueOnce([ + MockTemplateVersionExternalAuthGithub, + MockGitLabExternalAuth, + ]) + .mockResolvedValue([ + MockTemplateVersionExternalAuthGithubAuthenticated, + MockGitLabExternalAuth, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Both buttons render initially", async () => { + await canvas.findByRole("button", { name: /login with github/i }); + await canvas.findByRole("button", { name: /login with gitlab/i }); + }); + + await step("Click GitHub and wait for it to authenticate", async () => { + const githubButton = await canvas.findByRole("button", { + name: /login with github/i, + }); + await userEvent.click(githubButton); + + // Polling picks up the updated mock that returns GitHub as + // authenticated. The "Authenticated" text replaces the button. + await waitFor(() => { + expect(canvas.queryByRole("button", { name: /login with github/i })) + .not.toBeInTheDocument(); + }); + }); + + await step("GitLab button is still clickable", async () => { + const gitlabButton = await canvas.findByRole("button", { + name: /login with gitlab/i, + }); + expect(gitlabButton).toBeEnabled(); + }); + }, +}; From 65aafd0771578cf28780c0ef47cd9d05b9de8d74 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 10 Jun 2026 22:10:48 +0000 Subject: [PATCH 02/10] fix(site): make external auth polling state per-provider The external auth polling state was a single shared value for all providers. Clicking one provider's Login button set the global state to "polling", which disabled every other provider's button. Users had to refresh the page between each authentication. Change the state from a single ExternalAuthPollingState string to a Record keyed by provider ID. Each provider now independently tracks idle, polling, and abandoned states. The query refetchInterval activates when any provider is polling and each provider has its own 60-second timeout. Fixes coder/coder#22420 --- site/src/hooks/useExternalAuth.ts | 62 +++++++++++++------ .../modules/tasks/TaskPrompt/TaskPrompt.tsx | 12 ++-- .../CreateWorkspacePage.tsx | 60 +++++++++++++----- .../CreateWorkspacePageView.stories.tsx | 2 +- .../CreateWorkspacePageView.tsx | 10 +-- 5 files changed, 98 insertions(+), 48 deletions(-) diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts index 6c0db550d89..234bb17c8d3 100644 --- a/site/src/hooks/useExternalAuth.ts +++ b/site/src/hooks/useExternalAuth.ts @@ -5,13 +5,18 @@ 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 +24,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(([, s]) => s === "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.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 8468f755816..4ef4c1ce1f2 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -438,10 +438,8 @@ const ExternalAuthButtons: FC = ({ }) => { const { startPollingExternalAuth, - isPollingExternalAuth, externalAuthPollingState, } = useExternalAuth(versionId); - const shouldRetry = externalAuthPollingState === "abandoned"; return missedExternalAuth.map((auth) => { return ( @@ -449,29 +447,29 @@ const ExternalAuthButtons: FC = ({ - {shouldRetry && !auth.authenticated && ( + {externalAuthPollingState[auth.id] === "abandoned" && !auth.authenticated && ( - {externalAuthPollingState[auth.id] === "abandoned" && !auth.authenticated && ( - - - - - - Retry connecting to {auth.display_name} - - - )} + {externalAuthPollingState[auth.id] === "abandoned" && + !auth.authenticated && ( + + + + + + Retry connecting to {auth.display_name} + + + )} ); }); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index b7b8969afea..0875418ff23 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -10,7 +10,10 @@ import { MockTemplateVersionExternalAuthGithubAuthenticated, MockUserOwner, } from "#/testHelpers/entities"; -import { withAuthProvider, withDashboardProvider } from "#/testHelpers/storybook"; +import { + withAuthProvider, + withDashboardProvider, +} from "#/testHelpers/storybook"; import CreateWorkspacePage from "./CreateWorkspacePage"; const MockGitLabExternalAuth: TemplateVersionExternalAuth = { @@ -219,8 +222,9 @@ export const SequentialAuthFlow: Story = { // Polling picks up the updated mock that returns GitHub as // authenticated. The "Authenticated" text replaces the button. await waitFor(() => { - expect(canvas.queryByRole("button", { name: /login with github/i })) - .not.toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /login with github/i }), + ).not.toBeInTheDocument(); }); }); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 874de2bf7fd..d847031c583 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -470,9 +470,7 @@ const useExternalAuth = (versionId: string | undefined) => { setPollingState((prev) => ({ ...prev, [providerId]: "polling" })); }, []); - const isAnyPolling = Object.values(pollingState).some( - (s) => s === "polling", - ); + const isAnyPolling = Object.values(pollingState).some((s) => s === "polling"); const { data: externalAuth, isLoading: isLoadingExternalAuth } = useQuery({ ...templateVersionExternalAuth(versionId ?? ""), diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 649646d367a..db58d22d68e 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -590,7 +590,9 @@ export const CreateWorkspacePageView: FC = ({ auth={auth} isLoading={externalAuthPollingState[auth.id] === "polling"} onStartPolling={() => startPollingExternalAuth(auth.id)} - displayRetry={externalAuthPollingState[auth.id] === "abandoned"} + displayRetry={ + externalAuthPollingState[auth.id] === "abandoned" + } /> ))} From 21f4b7ba0b59866b4e55caf93a2b4238854a812a Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 11 Jun 2026 02:17:39 +0000 Subject: [PATCH 04/10] refactor: move MockGitLabExternalAuth to entities.ts and rename it to MockTemplateVersionExternalAuthAzure --- .../CreateWorkspacePage.stories.tsx | 26 +++++-------------- site/src/testHelpers/entities.ts | 10 +++++++ 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 0875418ff23..2423429ad41 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -2,10 +2,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { API } from "#/api/api"; -import type { TemplateVersionExternalAuth } from "#/api/typesGenerated"; import { MockTemplate, MockTemplateVersion, + MockTemplateVersionExternalAuthAzure, MockTemplateVersionExternalAuthGithub, MockTemplateVersionExternalAuthGithubAuthenticated, MockUserOwner, @@ -16,20 +16,6 @@ import { } from "#/testHelpers/storybook"; import CreateWorkspacePage from "./CreateWorkspacePage"; -const MockGitLabExternalAuth: TemplateVersionExternalAuth = { - id: "gitlab", - type: "gitlab", - authenticate_url: "https://example.com/external-auth/gitlab", - authenticated: false, - display_icon: "/icon/gitlab.svg", - display_name: "GitLab", -}; - -const MockGitLabExternalAuthAuthenticated: TemplateVersionExternalAuth = { - ...MockGitLabExternalAuth, - authenticated: true, -}; - /** * Mocks API.templateVersionDynamicParameters to immediately send an empty * DynamicParametersResponse so the page renders the form instead of the @@ -100,7 +86,7 @@ export const MultipleExternalAuth: Story = { beforeEach: () => { spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ MockTemplateVersionExternalAuthGithub, - MockGitLabExternalAuth, + MockTemplateVersionExternalAuthAzure, ]); }, play: async ({ canvasElement }) => { @@ -129,7 +115,7 @@ export const ClickingOneAuthDoesNotDisableOthers: Story = { beforeEach: () => { spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ MockTemplateVersionExternalAuthGithub, - MockGitLabExternalAuth, + MockTemplateVersionExternalAuthAzure, ]); }, play: async ({ canvasElement, step }) => { @@ -166,7 +152,7 @@ export const OneProviderAuthenticated: Story = { beforeEach: () => { spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ MockTemplateVersionExternalAuthGithubAuthenticated, - MockGitLabExternalAuth, + MockTemplateVersionExternalAuthAzure, ]); }, play: async ({ canvasElement, step }) => { @@ -198,11 +184,11 @@ export const SequentialAuthFlow: Story = { spyOn(API, "getTemplateVersionExternalAuth") .mockResolvedValueOnce([ MockTemplateVersionExternalAuthGithub, - MockGitLabExternalAuth, + MockTemplateVersionExternalAuthAzure, ]) .mockResolvedValue([ MockTemplateVersionExternalAuthGithubAuthenticated, - MockGitLabExternalAuth, + MockTemplateVersionExternalAuthAzure, ]); }, play: async ({ canvasElement, step }) => { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2972fc97d44..e2897c60112 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3638,6 +3638,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", From a61402ffc2979868e781bd6f985840d0edc39896 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 11 Jun 2026 02:21:31 +0000 Subject: [PATCH 05/10] refactor: declare isPollingExternalAuth and shouldRetry for each missed external auth --- .../modules/tasks/TaskPrompt/TaskPrompt.tsx | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 7d3c4135ba8..21481e6c2d4 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -440,15 +440,16 @@ const ExternalAuthButtons: FC = ({ useExternalAuth(versionId); return missedExternalAuth.map((auth) => { + const isPollingExternalAuth = + externalAuthPollingState[auth.id] === "polling"; + const shouldRetry = externalAuthPollingState[auth.id] === "abandoned"; + return (
- {externalAuthPollingState[auth.id] === "abandoned" && - !auth.authenticated && ( - - - - - - Retry connecting to {auth.display_name} - - - )} + {shouldRetry && !auth.authenticated && ( + + + + + + Retry connecting to {auth.display_name} + + + )}
); }); From 2403e751eb8ee8ac0917a620cadc3fcc83f9d452 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 11 Jun 2026 21:48:05 +0000 Subject: [PATCH 06/10] refactor: use global useExternalAuth hook for CreateWorkspacePage --- .../CreateWorkspacePage.tsx | 71 +------------------ 1 file changed, 1 insertion(+), 70 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index d847031c583..bfda7d3a2a3 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -15,7 +15,6 @@ import { checkAuthorization } from "#/api/queries/authCheck"; import { templateByName, templateVersion, - templateVersionExternalAuth, templateVersionPresets, } from "#/api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "#/api/queries/workspaces"; @@ -28,6 +27,7 @@ import type { } from "#/api/typesGenerated"; import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { useExternalAuth } from "#/hooks/useExternalAuth"; import { getInitialParameterValues } from "#/modules/workspaces/DynamicParameter/DynamicParameter"; import { generateWorkspaceName } from "#/modules/workspaces/generateWorkspaceName"; import { pageTitle } from "#/utils/page"; @@ -41,7 +41,6 @@ import { const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; -type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; const CreateWorkspacePage: FC = () => { const { organization: organizationName = "default", template: templateName } = @@ -461,74 +460,6 @@ const CreateWorkspacePage: FC = () => { ); }; -const useExternalAuth = (versionId: string | undefined) => { - const [pollingState, setPollingState] = useState< - Record - >({}); - - const startPollingExternalAuth = useCallback((providerId: string) => { - setPollingState((prev) => ({ ...prev, [providerId]: "polling" })); - }, []); - - const isAnyPolling = Object.values(pollingState).some((s) => s === "polling"); - - const { data: externalAuth, isLoading: isLoadingExternalAuth } = useQuery({ - ...templateVersionExternalAuth(versionId ?? ""), - enabled: Boolean(versionId), - refetchInterval: isAnyPolling ? 1000 : false, - }); - - // Stop polling individual providers once they authenticate. - useEffect(() => { - 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]); - - // Per-provider 60-second timeout. - useEffect(() => { - const pollingIds = Object.entries(pollingState) - .filter(([, s]) => s === "polling") - .map(([id]) => id); - - if (pollingIds.length === 0) { - return; - } - - const timers = pollingIds.map((id) => - setTimeout(() => { - setPollingState((prev) => - prev[id] === "polling" ? { ...prev, [id]: "abandoned" } : prev, - ); - }, 60_000), - ); - - return () => { - for (const t of timers) { - clearTimeout(t); - } - }; - }, [pollingState]); - - return { - startPollingExternalAuth, - externalAuth, - externalAuthPollingState: pollingState, - isLoadingExternalAuth, - }; -}; - const getAutofillParameters = ( urlSearchParams: URLSearchParams, ): AutofillBuildParameter[] => { From 68b584fc5bf95d110456b1de31600299948d084e Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 11 Jun 2026 22:08:53 +0000 Subject: [PATCH 07/10] test: expect Azure button instead of GitLab button --- .../CreateWorkspacePage.stories.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 2423429ad41..90be16a8fd7 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -95,12 +95,12 @@ export const MultipleExternalAuth: Story = { const githubButton = await canvas.findByRole("button", { name: /login with github/i, }); - const gitlabButton = await canvas.findByRole("button", { - name: /login with gitlab/i, + const azureButton = await canvas.findByRole("button", { + name: /login with azure/i, }); expect(githubButton).toBeEnabled(); - expect(gitlabButton).toBeEnabled(); + expect(azureButton).toBeEnabled(); }, }; @@ -124,19 +124,19 @@ export const ClickingOneAuthDoesNotDisableOthers: Story = { const githubButton = await canvas.findByRole("button", { name: /login with github/i, }); - const gitlabButton = await canvas.findByRole("button", { - name: /login with gitlab/i, + const azureButton = await canvas.findByRole("button", { + name: /login with azure/i, }); await step("Click GitHub auth button", async () => { await userEvent.click(githubButton); }); - await step("GitLab button remains enabled", async () => { + await step("Azure button remains enabled", async () => { // After the fix, each provider tracks its own polling state so // only the clicked provider shows a loading spinner. await waitFor(() => { - expect(gitlabButton).toBeEnabled(); + expect(azureButton).toBeEnabled(); }); }); }, @@ -162,11 +162,11 @@ export const OneProviderAuthenticated: Story = { await canvas.findByText("Authenticated"); }); - await step("GitLab login button is still enabled", async () => { - const gitlabButton = await canvas.findByRole("button", { - name: /login with gitlab/i, + await step("Azure login button is still enabled", async () => { + const azureButton = await canvas.findByRole("button", { + name: /login with azure/i, }); - expect(gitlabButton).toBeEnabled(); + expect(azureButton).toBeEnabled(); }); }, }; @@ -196,7 +196,7 @@ export const SequentialAuthFlow: Story = { await step("Both buttons render initially", async () => { await canvas.findByRole("button", { name: /login with github/i }); - await canvas.findByRole("button", { name: /login with gitlab/i }); + await canvas.findByRole("button", { name: /login with azure/i }); }); await step("Click GitHub and wait for it to authenticate", async () => { @@ -214,11 +214,11 @@ export const SequentialAuthFlow: Story = { }); }); - await step("GitLab button is still clickable", async () => { - const gitlabButton = await canvas.findByRole("button", { - name: /login with gitlab/i, + await step("Azure button is still clickable", async () => { + const azureButton = await canvas.findByRole("button", { + name: /login with azure/i, }); - expect(gitlabButton).toBeEnabled(); + expect(azureButton).toBeEnabled(); }); }, }; From b5d5697a9977023b8a861005a0b7bb04185c2376 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 11 Jun 2026 22:13:06 +0000 Subject: [PATCH 08/10] refactor: use descriptive variable name when evaluating pollingIds --- site/src/hooks/useExternalAuth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts index f36b77e41bc..81dcae5de06 100644 --- a/site/src/hooks/useExternalAuth.ts +++ b/site/src/hooks/useExternalAuth.ts @@ -46,7 +46,7 @@ export const useExternalAuth = (versionId: string | undefined) => { // Per-provider 60-second timeout. useEffect(() => { const pollingIds = Object.entries(pollingState) - .filter(([, s]) => s === "polling") + .filter(([, authPollingState]) => authPollingState === "polling") .map(([id]) => id); if (pollingIds.length === 0) { From a396269463496339e871003512ae3e3fb52ec016 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 11 Jun 2026 22:32:49 +0000 Subject: [PATCH 09/10] test: don't wait for Azure button to be enabled --- .../CreateWorkspacePage/CreateWorkspacePage.stories.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 90be16a8fd7..da204818816 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -132,12 +132,8 @@ export const ClickingOneAuthDoesNotDisableOthers: Story = { await userEvent.click(githubButton); }); - await step("Azure button remains enabled", async () => { - // After the fix, each provider tracks its own polling state so - // only the clicked provider shows a loading spinner. - await waitFor(() => { - expect(azureButton).toBeEnabled(); - }); + await step("Azure button remains enabled", () => { + expect(azureButton).toBeEnabled(); }); }, }; From b8da84e372eca10ddada0120fc64fdd827c504d3 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 18 Jun 2026 18:43:34 +0000 Subject: [PATCH 10/10] test: add story for TaskPrompt with multiple missing external auth providers --- .../tasks/TaskPrompt/TaskPrompt.stories.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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")