From 5e91079bdee23af45a6acad0a0aebc3a1021c30b Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 29 Jun 2026 06:45:07 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix(site/src):=20refresh=20p?= =?UTF-8?q?rovider=20state=20after=20device-flow=20exchange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #18039 upgraded react-query to v5, which removed onSuccess/onError/onSettled from useQuery (they only live on useMutation now). The migration updated the invalidateQueries argument shape but left the now-dead onSuccess in place on exchangeExternalAuthDevice. After a successful device-flow exchange the externalAuthProvider query was never invalidated, so the UI stayed on "Checking for authentication..." until manual refresh. Drop the dead onSuccess (and the queryClient param it depended on), and invalidate via a useEffect in ExternalAuthPage when the exchange query flips to isSuccess. Refs https://linear.app/codercom/issue/DEVEX-413 Refs https://github.com/coder/coder/pull/18039 --- site/src/api/queries/externalAuth.ts | 7 ------ .../ExternalAuthPage/ExternalAuthPage.tsx | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/site/src/api/queries/externalAuth.ts b/site/src/api/queries/externalAuth.ts index b0cd753cda4..310df8ac95c 100644 --- a/site/src/api/queries/externalAuth.ts +++ b/site/src/api/queries/externalAuth.ts @@ -27,7 +27,6 @@ export const externalAuthDevice = (providerId: string) => { export const exchangeExternalAuthDevice = ( providerId: string, deviceCode: string, - queryClient: QueryClient, ) => { return { queryFn: () => @@ -35,12 +34,6 @@ export const exchangeExternalAuthDevice = ( device_code: deviceCode, }), queryKey: ["external-auth", providerId, "device", deviceCode], - onSuccess: async () => { - // Force a refresh of the Git auth status. - await queryClient.invalidateQueries({ - queryKey: ["external-auth", providerId], - }); - }, }; }; diff --git a/site/src/pages/ExternalAuthPage/ExternalAuthPage.tsx b/site/src/pages/ExternalAuthPage/ExternalAuthPage.tsx index f66a09c2da4..2873063abe1 100644 --- a/site/src/pages/ExternalAuthPage/ExternalAuthPage.tsx +++ b/site/src/pages/ExternalAuthPage/ExternalAuthPage.tsx @@ -1,6 +1,6 @@ import { isAxiosError } from "axios"; import type { FC } from "react"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { useQuery, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; import type { ApiErrorResponse } from "#/api/errors"; @@ -24,7 +24,10 @@ const ExternalAuthPage: FC = () => { const [searchParams] = useSearchParams(); const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); - const externalAuthProviderOpts = externalAuthProvider(provider); + const externalAuthProviderOpts = useMemo( + () => externalAuthProvider(provider), + [provider], + ); const externalAuthProviderQuery = useQuery({ ...externalAuthProviderOpts, refetchOnWindowFocus: true, @@ -45,7 +48,6 @@ const ExternalAuthPage: FC = () => { ...exchangeExternalAuthDevice( provider, externalAuthDeviceQuery.data?.device_code ?? "", - queryClient, ), enabled: Boolean(externalAuthDeviceQuery.data), retry: isExchangeErrorRetryable, @@ -55,6 +57,23 @@ const ExternalAuthPage: FC = () => { refetchOnWindowFocus: false, }); + // Flip the UI out of polling once the exchange succeeds. Replaces the + // `onSuccess` that react-query v5 dropped from `useQuery`. `exact` avoids + // re-POSTing the one-time device code. + useEffect(() => { + if (!exchangeExternalAuthDeviceQuery.isSuccess) { + return; + } + queryClient.invalidateQueries({ + queryKey: externalAuthProviderOpts.queryKey, + exact: true, + }); + }, [ + exchangeExternalAuthDeviceQuery.isSuccess, + externalAuthProviderOpts.queryKey, + queryClient, + ]); + if (externalAuthProviderQuery.isLoading || !externalAuthProviderQuery.data) { return null; } From 46c991c3aaa854200ecc61f18f7af16d9ed140cd Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 29 Jun 2026 09:26:59 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix(site/e2e):=20reset=20bot?= =?UTF-8?q?h=20providers=20in=20external=20auth=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resetExternalAuthKey only deleted the link for gitAuth.webProvider, leaving the device-flow link in place across iterations. The device test passed once and then deterministically failed on every subsequent run: page sees authenticated=true immediately, never polls the mock token endpoint, and sentPending.wait() hangs until the 30s test timeout. Reset every provider the suite exercises. Refactored the per-link delete into a Promise-returning helper so errors actually propagate (the previous throws inside response callbacks crashed the worker instead of rejecting the call). Locally verified with --repeat-each=20: 61/61 passing in 4.8m. The previous behaviour was 100% pass on iter 1, 100% device failure on iter 2+. Refs https://linear.app/codercom/issue/DEVEX-413 --- site/e2e/hooks.ts | 70 ++++++++++++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/site/e2e/hooks.ts b/site/e2e/hooks.ts index e8ad7658bd2..ded1e067155 100644 --- a/site/e2e/hooks.ts +++ b/site/e2e/hooks.ts @@ -54,38 +54,58 @@ export const beforeCoderTest = (page: Page) => { }; export const resetExternalAuthKey = async (context: BrowserContext) => { - // Find the session token so we can destroy the external auth link between tests, to ensure valid authentication happens each time. + // Find the session token so we can destroy the external auth links between + // tests, to ensure valid authentication happens each time. const cookies = await context.cookies(); const sessionCookie = cookies.find((c) => c.name === "coder_session_token"); - const options = { - method: "DELETE", - hostname: "127.0.0.1", - port: coderPort, - path: `/api/v2/external-auth/${gitAuth.webProvider}?coder_session_token=${sessionCookie?.value}`, - }; - const req = http.request(options, (res) => { - let data = ""; - res.on("data", (chunk) => { - data += chunk; - }); + // Reset every provider the suite exercises so repeated runs against the same + // coderd instance get a clean slate; otherwise the device-flow link from a + // previous iteration trips the test on its next pass. + const providers = [gitAuth.webProvider, gitAuth.deviceProvider]; + await Promise.all( + providers.map((provider) => + deleteExternalAuthLink(provider, sessionCookie?.value), + ), + ); +}; + +const deleteExternalAuthLink = ( + provider: string, + sessionToken: string | undefined, +): Promise => { + return new Promise((resolve, reject) => { + const options = { + method: "DELETE", + hostname: "127.0.0.1", + port: coderPort, + path: `/api/v2/external-auth/${provider}?coder_session_token=${sessionToken}`, + }; - res.on("end", () => { - // 200 = link deleted; 404 = no link existed for this provider. - if (res.statusCode !== 200 && res.statusCode !== 404) { - console.error("failed to delete external auth link", data); - throw new Error( - `failed to delete external auth link: HTTP response ${res.statusCode}`, - ); - } + const req = http.request(options, (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", () => { + // 200 = link deleted; 404 = no link existed for this provider. + if (res.statusCode !== 200 && res.statusCode !== 404) { + console.error("failed to delete external auth link", data); + reject( + new Error( + `failed to delete external auth link: HTTP response ${res.statusCode}`, + ), + ); + return; + } + resolve(); + }); }); - }); - req.on("error", (err) => { - throw err.message; + req.on("error", reject); + req.end(); }); - - req.end(); }; const isApiCall = (urlString: string): boolean => { From f49d7b5259c277d962b285f39dd8ed135d36c6b2 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 28 Jul 2026 13:46:09 +1000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20refactor(site/e2e):=20use=20?= =?UTF-8?q?fetch=20to=20delete=20external=20auth=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/e2e/hooks.ts | 46 +++++++++++++--------------------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/site/e2e/hooks.ts b/site/e2e/hooks.ts index ded1e067155..4d7cbccb0c1 100644 --- a/site/e2e/hooks.ts +++ b/site/e2e/hooks.ts @@ -1,4 +1,3 @@ -import http from "node:http"; import type { BrowserContext, Page } from "@playwright/test"; import { coderPort, gitAuth } from "./constants"; @@ -70,42 +69,23 @@ export const resetExternalAuthKey = async (context: BrowserContext) => { ); }; -const deleteExternalAuthLink = ( +const deleteExternalAuthLink = async ( provider: string, sessionToken: string | undefined, ): Promise => { - return new Promise((resolve, reject) => { - const options = { - method: "DELETE", - hostname: "127.0.0.1", - port: coderPort, - path: `/api/v2/external-auth/${provider}?coder_session_token=${sessionToken}`, - }; - - const req = http.request(options, (res) => { - let data = ""; - res.on("data", (chunk) => { - data += chunk; - }); - - res.on("end", () => { - // 200 = link deleted; 404 = no link existed for this provider. - if (res.statusCode !== 200 && res.statusCode !== 404) { - console.error("failed to delete external auth link", data); - reject( - new Error( - `failed to delete external auth link: HTTP response ${res.statusCode}`, - ), - ); - return; - } - resolve(); - }); - }); + const response = await fetch( + `http://127.0.0.1:${coderPort}/api/v2/external-auth/${provider}?coder_session_token=${sessionToken}`, + { method: "DELETE" }, + ); - req.on("error", reject); - req.end(); - }); + // 200 = link deleted; 404 = no link existed for this provider. + if (response.status !== 200 && response.status !== 404) { + const data = await response.text(); + console.error("failed to delete external auth link", data); + throw new Error( + `failed to delete external auth link: HTTP response ${response.status}`, + ); + } }; const isApiCall = (urlString: string): boolean => {