From c0472a861fa32a345c8233c18e16afa54ba13a10 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 23 Jul 2026 17:25:03 -0700 Subject: [PATCH 01/32] feat(site): add OAuth2 dynamic client registration toggle to deployment settings Surfaces the admin-controlled DCR setting from GET/PUT /api/v2/oauth2-provider/settings (added in #27316) on the OAuth2 Applications deployment settings page. Enabling the switch requires confirming a warning dialog, since it lets any client self-register against the deployment per RFC 7591; disabling is immediate. --- site/src/api/api.ts | 13 ++++ site/src/api/queries/oauth2.ts | 19 +++++ .../OAuth2AppsSettingsPage.tsx | 30 +++++++- .../OAuth2AppsSettingsPageView.stories.tsx | 36 +++++++++- .../OAuth2AppsSettingsPageView.tsx | 72 ++++++++++++++++++- 5 files changed, 165 insertions(+), 5 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b87d08a9f2b..c6d5e0692a8 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2035,6 +2035,19 @@ class ApiMethods { await this.axios.delete(`/oauth2/tokens?client_id=${appId}`); }; + getOAuth2ProviderSettings = + async (): Promise => { + const resp = await this.axios.get("/api/v2/oauth2-provider/settings"); + return resp.data; + }; + + putOAuth2ProviderSettings = async ( + data: TypesGen.OAuth2ProviderSettings, + ): Promise => { + const resp = await this.axios.put("/api/v2/oauth2-provider/settings", data); + return resp.data; + }; + getAuditLogs = async ( options: TypesGen.AuditLogsRequest, ): Promise => { diff --git a/site/src/api/queries/oauth2.ts b/site/src/api/queries/oauth2.ts index 4881e8ad6c8..72b5dccf64e 100644 --- a/site/src/api/queries/oauth2.ts +++ b/site/src/api/queries/oauth2.ts @@ -9,6 +9,7 @@ export const oauth2ProviderAppSecretsKey = (appId: string) => oauth2ProviderAppKey(appId).concat("secrets"); const userAppsKey = (userId: string) => oauth2ProviderAppsKey.concat(userId); +const settingsKey = ["oauth2-provider", "settings"]; export const getGitHubDevice = () => { return { @@ -121,3 +122,21 @@ export const revokeApp = (queryClient: QueryClient, userId: string) => { }, }; }; + +export const getOAuth2ProviderSettings = () => { + return { + queryKey: settingsKey, + queryFn: () => API.getOAuth2ProviderSettings(), + }; +}; + +export const putOAuth2ProviderSettings = (queryClient: QueryClient) => { + return { + mutationFn: API.putOAuth2ProviderSettings, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: settingsKey, + }); + }, + }; +}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx index a3a14e332f8..c9a88768b59 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx @@ -1,15 +1,28 @@ import type { FC } from "react"; -import { useQuery } from "react-query"; -import { getApps } from "#/api/queries/oauth2"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { + getApps, + getOAuth2ProviderSettings, + putOAuth2ProviderSettings, +} from "#/api/queries/oauth2"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { pageTitle } from "#/utils/page"; import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView"; const OAuth2AppsSettingsPage: FC = () => { const { permissions } = useAuthenticated(); + const queryClient = useQueryClient(); const appsQuery = useQuery(getApps()); + const settingsQuery = useQuery({ + ...getOAuth2ProviderSettings(), + enabled: permissions.viewDeploymentConfig, + }); + const updateSettingsMutation = useMutation( + putOAuth2ProviderSettings(queryClient), + ); const canCreateApp = permissions.createOAuth2App; + const canEditSettings = permissions.editDeploymentConfig; return ( <> @@ -18,8 +31,19 @@ const OAuth2AppsSettingsPage: FC = () => { { + updateSettingsMutation.mutate({ + dynamic_client_registration_enabled: enabled, + }); + }} /> ); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index ef3cb419ce9..1ae565182d3 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -1,12 +1,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; import { MockOAuth2ProviderApps } from "#/testHelpers/entities"; import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView"; -const meta: Meta = { +const meta: Meta = { title: "pages/DeploymentSettingsPage/OAuth2AppsSettingsPageView", component: OAuth2AppsSettingsPageView, args: { canCreateApp: true, + canEditSettings: true, + dynamicClientRegistrationEnabled: false, + onDynamicClientRegistrationChange: fn(), }, }; export default meta; @@ -44,3 +48,33 @@ export const NoCreatePermissions: Story = { canCreateApp: false, }, }; + +export const DynamicClientRegistrationEnabled: Story = { + args: { + dynamicClientRegistrationEnabled: true, + }, +}; + +export const DynamicClientRegistrationReadOnly: Story = { + args: { + canEditSettings: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dcrSwitch = canvas.getByRole("switch", { + name: "Dynamic Client Registration", + }); + expect(dcrSwitch).toBeDisabled(); + }, +}; + +export const EnableDynamicClientRegistrationDialog: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("switch", { name: "Dynamic Client Registration" }), + ); + const body = within(canvasElement.ownerDocument.body); + await body.findByText("Enable Dynamic Client Registration"); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index be883b8b8b2..f0b658fc459 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -1,16 +1,26 @@ import { ChevronRightIcon, PlusIcon } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useId, useState } from "react"; import { Link, useNavigate } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { Label } from "#/components/Label/Label"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { Switch } from "#/components/Switch/Switch"; import { Table, TableBody, @@ -28,6 +38,9 @@ type OAuth2AppsSettingsProps = { isLoading: boolean; error: unknown; canCreateApp: boolean; + canEditSettings: boolean; + dynamicClientRegistrationEnabled: boolean | undefined; + onDynamicClientRegistrationChange: (enabled: boolean) => void; }; const AddApplicationButton: FC = () => ( @@ -44,7 +57,13 @@ const OAuth2AppsSettingsPageView: FC = ({ isLoading, error, canCreateApp, + canEditSettings, + dynamicClientRegistrationEnabled, + onDynamicClientRegistrationChange, }) => { + const dcrSwitchId = useId(); + const [isEnableDcrDialogOpen, setIsEnableDcrDialogOpen] = useState(false); + return (
= ({
)} + {dynamicClientRegistrationEnabled !== undefined && ( +
+ { + if (checked) { + setIsEnableDcrDialogOpen(true); + } else { + onDynamicClientRegistrationChange(false); + } + }} + /> + +
+ )} + + + + + Enable Dynamic Client Registration + + Warning: Any OAuth2 client will be able to register itself against + this deployment (RFC 7591) without prior approval from an + administrator. Only enable this if you intend to support + self-service client registration. + + + + + + + + + From 5eaecb1064095a2f238fe902f5918d4cf6a976d1 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 24 Jul 2026 18:38:15 -0700 Subject: [PATCH 02/32] test(site): cover the new oauth2 provider settings API and query helpers Adds coverage for the get/put oauth2-provider/settings client methods added in api.ts (correct URL, payload, and error propagation), and for the matching React Query helpers in queries/oauth2.ts (queryKey shape, mutationFn delegation, and that a successful update invalidates the settings query so the switch's on-screen state catches up). --- site/src/api/api.test.ts | 49 +++++++++++++++++++ site/src/api/queries/oauth2.test.ts | 75 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 site/src/api/queries/oauth2.test.ts diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 446467c52bb..f832533a53b 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -610,4 +610,53 @@ describe("api.ts", () => { ); }); }); + + describe("oauth2 provider settings", () => { + const settings: TypesGen.OAuth2ProviderSettings = { + dynamic_client_registration_enabled: true, + }; + + it("gets oauth2 provider settings", async () => { + vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({ + data: settings, + }); + + const result = await API.getOAuth2ProviderSettings(); + + expect(axiosInstance.get).toHaveBeenCalledWith( + "/api/v2/oauth2-provider/settings", + ); + expect(result).toStrictEqual(settings); + }); + + it("propagates errors when getting oauth2 provider settings", async () => { + const expectedError = new Error("request failed"); + vi.spyOn(axiosInstance, "get").mockRejectedValueOnce(expectedError); + + await expect(API.getOAuth2ProviderSettings()).rejects.toBe(expectedError); + }); + + it("updates oauth2 provider settings", async () => { + vi.spyOn(axiosInstance, "put").mockResolvedValueOnce({ + data: settings, + }); + + const result = await API.putOAuth2ProviderSettings(settings); + + expect(axiosInstance.put).toHaveBeenCalledWith( + "/api/v2/oauth2-provider/settings", + settings, + ); + expect(result).toStrictEqual(settings); + }); + + it("propagates errors when updating oauth2 provider settings", async () => { + const expectedError = new Error("request failed"); + vi.spyOn(axiosInstance, "put").mockRejectedValueOnce(expectedError); + + await expect(API.putOAuth2ProviderSettings(settings)).rejects.toBe( + expectedError, + ); + }); + }); }); diff --git a/site/src/api/queries/oauth2.test.ts b/site/src/api/queries/oauth2.test.ts new file mode 100644 index 00000000000..63d0164df9f --- /dev/null +++ b/site/src/api/queries/oauth2.test.ts @@ -0,0 +1,75 @@ +import { QueryClient } from "react-query"; +import { describe, expect, it, vi } from "vitest"; +import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; +import { getOAuth2ProviderSettings, putOAuth2ProviderSettings } from "./oauth2"; + +vi.mock("#/api/api", () => ({ + API: { + getOAuth2ProviderSettings: vi.fn(), + putOAuth2ProviderSettings: vi.fn(), + }, +})); + +const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + networkMode: "offlineFirst", + }, + }, + }); + +const settings: TypesGen.OAuth2ProviderSettings = { + dynamic_client_registration_enabled: true, +}; + +describe("getOAuth2ProviderSettings", () => { + it("uses a queryKey nested under the oauth2-provider prefix", () => { + expect(getOAuth2ProviderSettings().queryKey).toEqual([ + "oauth2-provider", + "settings", + ]); + }); + + it("fetches settings via the API client", async () => { + const getSettingsMock = vi.mocked(API.getOAuth2ProviderSettings); + getSettingsMock.mockResolvedValue(settings); + + const result = await getOAuth2ProviderSettings().queryFn(); + + expect(getSettingsMock).toHaveBeenCalled(); + expect(result).toEqual(settings); + }); +}); + +describe("putOAuth2ProviderSettings", () => { + it("delegates directly to the API client", async () => { + const putSettingsMock = vi.mocked(API.putOAuth2ProviderSettings); + putSettingsMock.mockResolvedValue(settings); + const queryClient = createTestQueryClient(); + + const result = + await putOAuth2ProviderSettings(queryClient).mutationFn(settings); + + expect(putSettingsMock).toHaveBeenCalledWith(settings); + expect(result).toEqual(settings); + }); + + it("invalidates the settings query on a successful update", async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(getOAuth2ProviderSettings().queryKey, { + dynamic_client_registration_enabled: false, + }); + + await putOAuth2ProviderSettings(queryClient).onSuccess(); + + expect( + queryClient.getQueryState(getOAuth2ProviderSettings().queryKey) + ?.isInvalidated, + ).toBe(true); + }); +}); From af8db900e86276a0e56daee5d9aebea0219e6e41 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 28 Jul 2026 15:15:46 -0700 Subject: [PATCH 03/32] refactor(site): adopt designer mockup for the DCR setting Applies Tracy's mockup from tj/oauth2-apps-pagination. The page is now tabbed (Applications | Settings) so future OAuth2 settings have a home, and the DCR control moves off a Switch. A switch implies an immediate on/off flip, which reads wrong when a confirmation dialog sits in front of it. The setting is now a titled section with a description, an 'Enabled' badge as a persistent state indicator, and an Enable/Disable button. A button carries a confirmation step without misrepresenting its own cost. Enabling still confirms through a destructive-variant dialog; disabling stays immediate. The Settings tab is hidden when the settings query is skipped for users without viewDeploymentConfig. Excludes the pagination work that shares the mockup branch. Co-authored-by: Tracy Johnson --- .../OAuth2AppsSettingsPageView.stories.tsx | 83 +++++++- .../OAuth2AppsSettingsPageView.tsx | 200 +++++++++++------- 2 files changed, 204 insertions(+), 79 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index 1ae565182d3..f1e8ec29a8a 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -49,10 +49,27 @@ export const NoCreatePermissions: Story = { }, }; +export const DynamicClientRegistrationDisabled: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect(canvas.getByRole("button", { name: "Enable" })).toBeEnabled(); + await expect(canvas.queryByText("Enabled")).not.toBeInTheDocument(); + }, +}; + export const DynamicClientRegistrationEnabled: Story = { args: { dynamicClientRegistrationEnabled: true, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect(canvas.getByText("Enabled")).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Disable" })).toBeVisible(); + }, }; export const DynamicClientRegistrationReadOnly: Story = { @@ -61,20 +78,70 @@ export const DynamicClientRegistrationReadOnly: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const dcrSwitch = canvas.getByRole("switch", { - name: "Dynamic Client Registration", - }); - expect(dcrSwitch).toBeDisabled(); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect(canvas.getByRole("button", { name: "Enable" })).toBeDisabled(); }, }; export const EnableDynamicClientRegistrationDialog: Story = { - play: async ({ canvasElement }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click( - canvas.getByRole("switch", { name: "Dynamic Client Registration" }), + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + await userEvent.click(canvas.getByRole("button", { name: "Enable" })); + + const body = within(canvasElement.ownerDocument.body); + await body.findByText("Enable Dynamic Client Registration?"); + await expect(args.onDynamicClientRegistrationChange).not.toHaveBeenCalled(); + + await userEvent.click(body.getByRole("button", { name: "Confirm" })); + await expect(args.onDynamicClientRegistrationChange).toHaveBeenCalledWith( + true, ); + }, +}; + +export const CancelEnableDynamicClientRegistration: Story = { + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + await userEvent.click(canvas.getByRole("button", { name: "Enable" })); + const body = within(canvasElement.ownerDocument.body); - await body.findByText("Enable Dynamic Client Registration"); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + + await expect(args.onDynamicClientRegistrationChange).not.toHaveBeenCalled(); + }, +}; + +// Disabling skips the confirmation dialog, unlike enabling. +export const DisableDynamicClientRegistration: Story = { + args: { + dynamicClientRegistrationEnabled: true, + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + await userEvent.click(canvas.getByRole("button", { name: "Disable" })); + + await expect(args.onDynamicClientRegistrationChange).toHaveBeenCalledWith( + false, + ); + }, +}; + +export const SettingsTabHiddenWithoutPermission: Story = { + args: { + dynamicClientRegistrationEnabled: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("tab", { name: "Applications" }), + ).toBeVisible(); + await expect( + canvas.queryByRole("tab", { name: "Settings" }), + ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index f0b658fc459..7384f7ee1b3 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -5,22 +5,22 @@ import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { Dialog, + DialogActions, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "#/components/Dialog/Dialog"; -import { Label } from "#/components/Label/Label"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { Switch } from "#/components/Switch/Switch"; import { Table, TableBody, @@ -31,6 +31,12 @@ import { } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "#/components/Tabs/Tabs"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; type OAuth2AppsSettingsProps = { @@ -61,8 +67,9 @@ const OAuth2AppsSettingsPageView: FC = ({ dynamicClientRegistrationEnabled, onDynamicClientRegistrationChange, }) => { - const dcrSwitchId = useId(); - const [isEnableDcrDialogOpen, setIsEnableDcrDialogOpen] = useState(false); + // The settings query is skipped for users without viewDeploymentConfig, so + // an undefined value means the settings tab has nothing to show. + const canViewSettings = dynamicClientRegistrationEnabled !== undefined; return (
@@ -81,82 +88,133 @@ const OAuth2AppsSettingsPageView: FC = ({
)} - {dynamicClientRegistrationEnabled !== undefined && ( -
- { - if (checked) { - setIsEnableDcrDialogOpen(true); - } else { - onDynamicClientRegistrationChange(false); - } - }} - /> - -
- )} + + + Applications + {canViewSettings && ( + Settings + )} + + + +
+ + + Name + Callback URL + + Open + + + + + {isLoading ? ( + + ) : !error && (!apps || apps.length === 0) ? ( + : undefined} + /> + ) : ( + apps?.map((app) => ) + )} + +
+ + + {canViewSettings && ( + + + + )} + + + ); +}; + +type DynamicClientRegistrationSettingProps = { + enabled: boolean; + canEdit: boolean; + onChange: (enabled: boolean) => void; +}; - = ({ enabled, canEdit, onChange }) => { + const headingId = useId(); + const [isEnableDialogOpen, setIsEnableDialogOpen] = useState(false); + + return ( + <> +
- - - Enable Dynamic Client Registration +
+
+

+ Dynamic Client Registration +

+ {enabled && ( + + Enabled + + )} +
+

+ Allow OAuth2 clients to register themselves against this deployment + without prior administrator approval (RFC 7591). +

+
+ + {enabled ? ( + + ) : ( + + )} +
+ + + + + Enable Dynamic Client Registration? - Warning: Any OAuth2 client will be able to register itself against - this deployment (RFC 7591) without prior approval from an - administrator. Only enable this if you intend to support - self-service client registration. + Only enable Dynamic Client Registration if you intend to support + self-service OAuth2 client registration. - - - + onCancel={() => setIsEnableDialogOpen(false)} + /> - - - - - Name - Callback URL - - Open - - - - - {isLoading ? ( - - ) : !error && (!apps || apps.length === 0) ? ( - : undefined} - /> - ) : ( - apps?.map((app) => ) - )} - -
- + ); }; From 5c035618560f8fb2e05109303e8c31808c89061f Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 29 Jul 2026 13:57:05 -0700 Subject: [PATCH 04/32] refactor(site): move DynamicClientRegistrationSetting to its own file --- .../DynamicClientRegistrationSetting.tsx | 93 ++++++++++++++++++ .../OAuth2AppsSettingsPageView.tsx | 94 +------------------ 2 files changed, 95 insertions(+), 92 deletions(-) create mode 100644 site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx new file mode 100644 index 00000000000..99940cca8f2 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -0,0 +1,93 @@ +import { type FC, useId, useState } from "react"; +import { Badge } from "#/components/Badge/Badge"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogActions, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; + +type DynamicClientRegistrationSettingProps = { + enabled: boolean; + canEdit: boolean; + onChange: (enabled: boolean) => void; +}; + +export const DynamicClientRegistrationSetting: FC< + DynamicClientRegistrationSettingProps +> = ({ enabled, canEdit, onChange }) => { + const headingId = useId(); + const [isEnableDialogOpen, setIsEnableDialogOpen] = useState(false); + + return ( + <> +
+
+
+

+ Dynamic Client Registration +

+ {enabled && ( + + Enabled + + )} +
+

+ Allow OAuth2 clients to register themselves against this deployment + without prior administrator approval (RFC 7591). +

+
+ + {enabled ? ( + + ) : ( + + )} +
+ + + + + Enable Dynamic Client Registration? + + Only enable Dynamic Client Registration if you intend to support + self-service OAuth2 client registration. + + + + { + setIsEnableDialogOpen(false); + onChange(true); + }} + onCancel={() => setIsEnableDialogOpen(false)} + /> + + + + + ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index 7384f7ee1b3..677a21b0975 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -1,21 +1,11 @@ import { ChevronRightIcon, PlusIcon } from "lucide-react"; -import { type FC, useId, useState } from "react"; +import type { FC } from "react"; import { Link, useNavigate } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogActions, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; import { SettingsHeader, SettingsHeaderDescription, @@ -38,6 +28,7 @@ import { TabsTrigger, } from "#/components/Tabs/Tabs"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import { DynamicClientRegistrationSetting } from "./DynamicClientRegistrationSetting"; type OAuth2AppsSettingsProps = { apps?: TypesGen.OAuth2ProviderApp[]; @@ -137,87 +128,6 @@ const OAuth2AppsSettingsPageView: FC = ({ ); }; -type DynamicClientRegistrationSettingProps = { - enabled: boolean; - canEdit: boolean; - onChange: (enabled: boolean) => void; -}; - -const DynamicClientRegistrationSetting: FC< - DynamicClientRegistrationSettingProps -> = ({ enabled, canEdit, onChange }) => { - const headingId = useId(); - const [isEnableDialogOpen, setIsEnableDialogOpen] = useState(false); - - return ( - <> -
-
-
-

- Dynamic Client Registration -

- {enabled && ( - - Enabled - - )} -
-

- Allow OAuth2 clients to register themselves against this deployment - without prior administrator approval (RFC 7591). -

-
- - {enabled ? ( - - ) : ( - - )} -
- - - - - Enable Dynamic Client Registration? - - Only enable Dynamic Client Registration if you intend to support - self-service OAuth2 client registration. - - - - { - setIsEnableDialogOpen(false); - onChange(true); - }} - onCancel={() => setIsEnableDialogOpen(false)} - /> - - - - - ); -}; - type OAuth2AppRowProps = { app: TypesGen.OAuth2ProviderApp; }; From 659cfa44d6aa18a1bb7cdae2cf9213b9c8619b17 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 29 Jul 2026 14:06:15 -0700 Subject: [PATCH 05/32] test(site): co-locate DynamicClientRegistrationSetting stories --- ...namicClientRegistrationSetting.stories.tsx | 101 ++++++++++++++++++ .../OAuth2AppsSettingsPageView.stories.tsx | 72 +------------ 2 files changed, 106 insertions(+), 67 deletions(-) create mode 100644 site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx new file mode 100644 index 00000000000..91c1182c5b3 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { DynamicClientRegistrationSetting } from "./DynamicClientRegistrationSetting"; + +const meta: Meta = { + title: "pages/DeploymentSettingsPage/DynamicClientRegistrationSetting", + component: DynamicClientRegistrationSetting, + args: { + enabled: false, + canEdit: true, + onChange: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Disabled: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByRole("button", { name: "Enable" })).toBeEnabled(); + await expect(canvas.queryByText("Enabled")).not.toBeInTheDocument(); + }, +}; + +export const Enabled: Story = { + args: { + enabled: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText("Enabled")).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Disable" })).toBeVisible(); + }, +}; + +export const ReadOnly: Story = { + args: { + canEdit: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByRole("button", { name: "Enable" })).toBeDisabled(); + }, +}; + +export const EnabledReadOnly: Story = { + args: { + enabled: true, + canEdit: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("button", { name: "Disable" }), + ).toBeDisabled(); + }, +}; + +export const EnableShowsConfirmationDialog: Story = { + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Enable" })); + + const body = within(canvasElement.ownerDocument.body); + await body.findByText("Enable Dynamic Client Registration?"); + await expect(args.onChange).not.toHaveBeenCalled(); + + await userEvent.click(body.getByRole("button", { name: "Confirm" })); + await expect(args.onChange).toHaveBeenCalledWith(true); + }, +}; + +export const CancelEnable: Story = { + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Enable" })); + + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + + await expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + +// Disabling skips the confirmation dialog, unlike enabling. +export const Disable: Story = { + args: { + enabled: true, + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Disable" })); + + await expect(args.onChange).toHaveBeenCalledWith(false); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index f1e8ec29a8a..ffcc85563ba 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -49,81 +49,19 @@ export const NoCreatePermissions: Story = { }, }; -export const DynamicClientRegistrationDisabled: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); - - await expect(canvas.getByRole("button", { name: "Enable" })).toBeEnabled(); - await expect(canvas.queryByText("Enabled")).not.toBeInTheDocument(); - }, -}; - -export const DynamicClientRegistrationEnabled: Story = { +// The setting's own behavior is covered by +// DynamicClientRegistrationSetting.stories.tsx. This story covers the wiring +// between the two: rendering "Disable" proves the enabled state is threaded +// through, and clicking it proves the change handler is connected. +export const SettingsTabRendersDynamicClientRegistration: Story = { args: { dynamicClientRegistrationEnabled: true, }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); - - await expect(canvas.getByText("Enabled")).toBeVisible(); - await expect(canvas.getByRole("button", { name: "Disable" })).toBeVisible(); - }, -}; - -export const DynamicClientRegistrationReadOnly: Story = { - args: { - canEditSettings: false, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); - - await expect(canvas.getByRole("button", { name: "Enable" })).toBeDisabled(); - }, -}; - -export const EnableDynamicClientRegistrationDialog: Story = { - play: async ({ args, canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); - await userEvent.click(canvas.getByRole("button", { name: "Enable" })); - - const body = within(canvasElement.ownerDocument.body); - await body.findByText("Enable Dynamic Client Registration?"); - await expect(args.onDynamicClientRegistrationChange).not.toHaveBeenCalled(); - - await userEvent.click(body.getByRole("button", { name: "Confirm" })); - await expect(args.onDynamicClientRegistrationChange).toHaveBeenCalledWith( - true, - ); - }, -}; - -export const CancelEnableDynamicClientRegistration: Story = { play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); - await userEvent.click(canvas.getByRole("button", { name: "Enable" })); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click(body.getByRole("button", { name: "Cancel" })); - - await expect(args.onDynamicClientRegistrationChange).not.toHaveBeenCalled(); - }, -}; - -// Disabling skips the confirmation dialog, unlike enabling. -export const DisableDynamicClientRegistration: Story = { - args: { - dynamicClientRegistrationEnabled: true, - }, - play: async ({ args, canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); await userEvent.click(canvas.getByRole("button", { name: "Disable" })); - await expect(args.onDynamicClientRegistrationChange).toHaveBeenCalledWith( false, ); From 61711b0190e8c0d80dd05df0e9aa5f47b4a2c08b Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 29 Jul 2026 14:28:48 -0700 Subject: [PATCH 06/32] refactor(site): use ConfirmDialog for the DCR confirmation --- ...namicClientRegistrationSetting.stories.tsx | 5 ++- .../DynamicClientRegistrationSetting.tsx | 44 ++++++------------- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index 91c1182c5b3..7115edc73f2 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -70,7 +70,10 @@ export const EnableShowsConfirmationDialog: Story = { await body.findByText("Enable Dynamic Client Registration?"); await expect(args.onChange).not.toHaveBeenCalled(); - await userEvent.click(body.getByRole("button", { name: "Confirm" })); + // The dialog's confirm button shares its accessible name with the trigger + // button behind it, so scope the query to the dialog. + const dialog = within(body.getByTestId("dialog")); + await userEvent.click(dialog.getByRole("button", { name: "Enable" })); await expect(args.onChange).toHaveBeenCalledWith(true); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 99940cca8f2..43a2ee4f9ec 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -1,15 +1,7 @@ import { type FC, useId, useState } from "react"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogActions, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; +import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; type DynamicClientRegistrationSettingProps = { enabled: boolean; @@ -67,27 +59,19 @@ export const DynamicClientRegistrationSetting: FC< )} - - - - Enable Dynamic Client Registration? - - Only enable Dynamic Client Registration if you intend to support - self-service OAuth2 client registration. - - - - { - setIsEnableDialogOpen(false); - onChange(true); - }} - onCancel={() => setIsEnableDialogOpen(false)} - /> - - - + { + setIsEnableDialogOpen(false); + onChange(true); + }} + onClose={() => setIsEnableDialogOpen(false)} + title="Enable Dynamic Client Registration?" + confirmText="Enable" + description="Only enable Dynamic Client Registration if you intend to support self-service OAuth2 client registration." + /> ); }; From 5a726d6ebcf00c6f587713af2a62462258628c5c Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 29 Jul 2026 14:39:36 -0700 Subject: [PATCH 07/32] feat(site): add loading and pending states to the DCR setting --- ...namicClientRegistrationSetting.stories.tsx | 26 +++++++++++++++++++ .../DynamicClientRegistrationSetting.tsx | 10 ++++--- .../OAuth2AppsSettingsPage.tsx | 4 +++ .../OAuth2AppsSettingsPageView.stories.tsx | 25 +++++++++++++++++- .../OAuth2AppsSettingsPageView.tsx | 25 +++++++++++------- 5 files changed, 77 insertions(+), 13 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index 7115edc73f2..f28c92b20af 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -8,6 +8,7 @@ const meta: Meta = { args: { enabled: false, canEdit: true, + isUpdating: false, onChange: fn(), }, }; @@ -90,6 +91,31 @@ export const CancelEnable: Story = { }, }; +export const Updating: Story = { + args: { + isUpdating: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByRole("button", { name: "Enable" })).toBeDisabled(); + }, +}; + +export const UpdatingWhileEnabled: Story = { + args: { + enabled: true, + isUpdating: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("button", { name: "Disable" }), + ).toBeDisabled(); + }, +}; + // Disabling skips the confirmation dialog, unlike enabling. export const Disable: Story = { args: { diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 43a2ee4f9ec..74fc0fcd447 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -2,16 +2,18 @@ import { type FC, useId, useState } from "react"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { Spinner } from "#/components/Spinner/Spinner"; type DynamicClientRegistrationSettingProps = { enabled: boolean; canEdit: boolean; + isUpdating: boolean; onChange: (enabled: boolean) => void; }; export const DynamicClientRegistrationSetting: FC< DynamicClientRegistrationSettingProps -> = ({ enabled, canEdit, onChange }) => { +> = ({ enabled, canEdit, isUpdating, onChange }) => { const headingId = useId(); const [isEnableDialogOpen, setIsEnableDialogOpen] = useState(false); @@ -44,16 +46,18 @@ export const DynamicClientRegistrationSetting: FC< {enabled ? ( ) : ( )} diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx index c9a88768b59..2505998ff43 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx @@ -22,6 +22,7 @@ const OAuth2AppsSettingsPage: FC = () => { ); const canCreateApp = permissions.createOAuth2App; + const canViewSettings = permissions.viewDeploymentConfig; const canEditSettings = permissions.editDeploymentConfig; return ( @@ -35,7 +36,10 @@ const OAuth2AppsSettingsPage: FC = () => { appsQuery.error ?? settingsQuery.error ?? updateSettingsMutation.error } canCreateApp={canCreateApp} + canViewSettings={canViewSettings} canEditSettings={canEditSettings} + isLoadingSettings={settingsQuery.isLoading} + isUpdatingSettings={updateSettingsMutation.isPending} dynamicClientRegistrationEnabled={ settingsQuery.data?.dynamic_client_registration_enabled } diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index ffcc85563ba..16ef786b5bc 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -8,7 +8,10 @@ const meta: Meta = { component: OAuth2AppsSettingsPageView, args: { canCreateApp: true, + canViewSettings: true, canEditSettings: true, + isLoadingSettings: false, + isUpdatingSettings: false, dynamicClientRegistrationEnabled: false, onDynamicClientRegistrationChange: fn(), }, @@ -70,7 +73,7 @@ export const SettingsTabRendersDynamicClientRegistration: Story = { export const SettingsTabHiddenWithoutPermission: Story = { args: { - dynamicClientRegistrationEnabled: undefined, + canViewSettings: false, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -83,3 +86,23 @@ export const SettingsTabHiddenWithoutPermission: Story = { ).not.toBeInTheDocument(); }, }; + +// The tab is present from first paint so it does not shift into the tab bar +// once the settings request resolves. +export const SettingsTabLoading: Story = { + args: { + isLoadingSettings: true, + dynamicClientRegistrationEnabled: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("tab", { name: "Settings" })).toBeVisible(); + + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect(canvas.getByLabelText("Loading settings")).toBeVisible(); + await expect( + canvas.queryByRole("button", { name: "Enable" }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index 677a21b0975..9e0cce052fb 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -6,6 +6,7 @@ import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; +import { Loader } from "#/components/Loader/Loader"; import { SettingsHeader, SettingsHeaderDescription, @@ -35,7 +36,10 @@ type OAuth2AppsSettingsProps = { isLoading: boolean; error: unknown; canCreateApp: boolean; + canViewSettings: boolean; canEditSettings: boolean; + isLoadingSettings: boolean; + isUpdatingSettings: boolean; dynamicClientRegistrationEnabled: boolean | undefined; onDynamicClientRegistrationChange: (enabled: boolean) => void; }; @@ -54,14 +58,13 @@ const OAuth2AppsSettingsPageView: FC = ({ isLoading, error, canCreateApp, + canViewSettings, canEditSettings, + isLoadingSettings, + isUpdatingSettings, dynamicClientRegistrationEnabled, onDynamicClientRegistrationChange, }) => { - // The settings query is skipped for users without viewDeploymentConfig, so - // an undefined value means the settings tab has nothing to show. - const canViewSettings = dynamicClientRegistrationEnabled !== undefined; - return (
= ({ {canViewSettings && ( - + {isLoadingSettings && } + {dynamicClientRegistrationEnabled !== undefined && ( + + )} )} From bd0231a46ebdd2fb570c7ff014c9e10d98fba858 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 29 Jul 2026 14:55:41 -0700 Subject: [PATCH 08/32] fix(site): close the DCR dialog when the setting is enabled elsewhere --- .../DynamicClientRegistrationSetting.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 74fc0fcd447..0a81276223f 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -66,7 +66,9 @@ export const DynamicClientRegistrationSetting: FC< { setIsEnableDialogOpen(false); onChange(true); From b0cba2fb1c827951efcb380ee9ad7b7b7f117ce0 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 29 Jul 2026 15:15:27 -0700 Subject: [PATCH 09/32] feat(site): link the OAuth2 settings tab to a query param --- .../OAuth2AppsSettingsPageView.stories.tsx | 38 +++++++++++++++++++ .../OAuth2AppsSettingsPageView.tsx | 14 ++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index 16ef786b5bc..9741e34c5fb 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { MockOAuth2ProviderApps } from "#/testHelpers/entities"; import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView"; @@ -87,6 +88,43 @@ export const SettingsTabHiddenWithoutPermission: Story = { }, }; +export const SettingsTabFromUrl: Story = { + parameters: { + reactRouter: reactRouterParameters({ + location: { searchParams: { tab: "settings" } }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByRole("tab", { name: "Settings" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(canvas.getByRole("button", { name: "Enable" })).toBeVisible(); + }, +}; + +// An unpermitted deep link selects the applications tab rather than leaving no +// tab selected at all. +export const UnpermittedTabFromUrlFallsBack: Story = { + args: { + canViewSettings: false, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { searchParams: { tab: "settings" } }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("tab", { name: "Applications" }), + ).toHaveAttribute("aria-selected", "true"); + }, +}; + // The tab is present from first paint so it does not shift into the tab bar // once the settings request resolves. export const SettingsTabLoading: Story = { diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index 9e0cce052fb..d90209aef48 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -29,6 +29,7 @@ import { TabsTrigger, } from "#/components/Tabs/Tabs"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import { useSearchParamsKey } from "#/hooks/useSearchParamsKey"; import { DynamicClientRegistrationSetting } from "./DynamicClientRegistrationSetting"; type OAuth2AppsSettingsProps = { @@ -65,6 +66,17 @@ const OAuth2AppsSettingsPageView: FC = ({ dynamicClientRegistrationEnabled, onDynamicClientRegistrationChange, }) => { + const tabState = useSearchParamsKey({ + key: "tab", + defaultValue: "applications", + }); + // Unknown values, and the settings tab for users who cannot view it, fall + // back to the applications tab rather than selecting nothing. + const activeTab = + tabState.value === "settings" && canViewSettings + ? "settings" + : "applications"; + return (
= ({
)} - + Applications {canViewSettings && ( From 595bc6313e376f38938cbd43e714424fa738c12a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 30 Jul 2026 14:29:19 -0700 Subject: [PATCH 10/32] fix(site): stop the DCR dialog reopening without user action The confirmation dialog's visibility was ANDed with the server-side enabled value, which hid the dialog without clearing the state that opened it. MUI invokes onClose only for cancel, backdrop, and escape, so a prop-driven close left the intent latched, and the dialog reappeared on its own once the setting became disabled again. An admin who clicked Disable was then asked to enable the setting they had just turned off. Dialog visibility now follows only the admin's own intent. Confirming against an already-enabled setting sends a redundant request, which is idempotent against a single-field settings object and preferable to a dialog that opens itself. Add a story that drives the enabled prop while the dialog is open. It fails against the previous behavior. --- ...namicClientRegistrationSetting.stories.tsx | 93 ++++++++++++++++++- .../DynamicClientRegistrationSetting.tsx | 4 +- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index f28c92b20af..14c0647b994 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, within } from "storybook/test"; +import { useState } from "react"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { DynamicClientRegistrationSetting } from "./DynamicClientRegistrationSetting"; const meta: Meta = { @@ -116,6 +117,96 @@ export const UpdatingWhileEnabled: Story = { }, }; +/** + * The dialog's visibility follows only the admin's own intent, never the + * server value. When the setting is enabled elsewhere while the dialog is + * open, the dialog stays put and the admin closes it themselves. It must + * never open, close, or reopen on its own as `enabled` changes underneath. + * + * The external-change buttons stack above the dialog's backdrop so they stay + * clickable while it is open. + */ +export const SurvivesExternalEnabledChanges: Story = { + render: function Harness(args) { + const [enabled, setEnabled] = useState(false); + + return ( +
+
+ + +
+ + { + setEnabled(next); + args.onChange(next); + }} + /> +
+ ); + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + const title = "Enable Dynamic Client Registration?"; + + // The dialog animates in and out over ~225ms, so it is present but + // transparent on the way in and still opaque on the way out. Anything + // asserting that the dialog did not close has to outlast that window, or + // a dialog already fading out still reads as visible. + const settleTransition = () => + new Promise((resolve) => setTimeout(resolve, 400)); + + // Grab these before opening the dialog. MUI's modal marks everything + // outside itself aria-hidden, and role queries skip aria-hidden nodes, + // so a lookup by role after the dialog opens will not find them. + const externalEnable = canvas.getByRole("button", { + name: "Simulate external enable", + }); + const externalDisable = canvas.getByRole("button", { + name: "Simulate external disable", + }); + + await userEvent.click(canvas.getByRole("button", { name: "Enable" })); + await waitFor(() => expect(body.getByText(title)).toBeVisible()); + const dialog = body.getByTestId("dialog"); + + // Enabled elsewhere. The dialog ignores it: the admin's intent to + // confirm is theirs to resolve, not the server's. + await userEvent.click(externalEnable); + await expect(canvas.getByText("Enabled")).toBeVisible(); + await settleTransition(); + await expect(body.getByText(title)).toBeVisible(); + // Still the same node, so it was never torn down and rebuilt. + await expect(body.getByTestId("dialog")).toBe(dialog); + + // And disabled again, the transition that used to resurrect it. + await userEvent.click(externalDisable); + await settleTransition(); + await expect(body.getByTestId("dialog")).toBe(dialog); + + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(body.queryByText(title)).not.toBeInTheDocument(), + ); + + // Once the admin has closed it, no amount of external churn brings it + // back. + await userEvent.click(externalEnable); + await userEvent.click(externalDisable); + await settleTransition(); + await expect(body.queryByText(title)).not.toBeInTheDocument(); + await expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + // Disabling skips the confirmation dialog, unlike enabling. export const Disable: Story = { args: { diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 0a81276223f..74fc0fcd447 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -66,9 +66,7 @@ export const DynamicClientRegistrationSetting: FC< { setIsEnableDialogOpen(false); onChange(true); From 8bdf4a1fbe732fe15ed0db4235e9aaa015c80abc Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 30 Jul 2026 14:59:57 -0700 Subject: [PATCH 11/32] fix(site): name the consequences in the DCR confirmation dialog The dialog's only text restated the action as its own precondition, "only enable X if you intend to support X", so it carried less information than the section description it covers. An admin confirming had no statement of who can register or whether the change is reversible. The description now names both. Registration requires no Coder account and no administrator approval, and disabling blocks new registrations without revoking clients that already registered. The second point is documented in docs/admin/integrations/oauth2-provider.md but was absent from the UI. Assert both phrases in EnableShowsConfirmationDialog so the copy cannot be silently weakened. --- .../DynamicClientRegistrationSetting.stories.tsx | 11 +++++++++++ .../DynamicClientRegistrationSetting.tsx | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index 14c0647b994..088b0052e3b 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -75,6 +75,17 @@ export const EnableShowsConfirmationDialog: Story = { // The dialog's confirm button shares its accessible name with the trigger // button behind it, so scope the query to the dialog. const dialog = within(body.getByTestId("dialog")); + + // The dialog covers the section description, so it is the last thing the + // admin reads before enabling. It has to name both what enabling exposes + // and what disabling does not undo, or the confirm click decides nothing. + await expect( + dialog.getByText(/no Coder account and no administrator approval/), + ).toBeInTheDocument(); + await expect( + dialog.getByText(/does not revoke clients that already registered/), + ).toBeInTheDocument(); + await userEvent.click(dialog.getByRole("button", { name: "Enable" })); await expect(args.onChange).toHaveBeenCalledWith(true); }, diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 74fc0fcd447..ebe9da19200 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -74,7 +74,7 @@ export const DynamicClientRegistrationSetting: FC< onClose={() => setIsEnableDialogOpen(false)} title="Enable Dynamic Client Registration?" confirmText="Enable" - description="Only enable Dynamic Client Registration if you intend to support self-service OAuth2 client registration." + description="Any client that can reach this deployment will be able to register itself as an OAuth2 application, with no Coder account and no administrator approval. Disabling later blocks new registrations but does not revoke clients that already registered." /> ); From 45baa4c43063540db5ac245da682ea07bf0099b0 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 30 Jul 2026 15:56:21 -0700 Subject: [PATCH 12/32] fix(site): explain why the DCR buttons are disabled A native disabled button takes no focus and no pointer events, so it cannot carry the reason it is disabled. An admin with viewDeploymentConfig but not editDeploymentConfig reaches the Settings tab, since read is what gates it, and saw only a greyed button with no way to tell a permission problem from a broken page. The auditor role holds exactly that combination. State the reason in the section instead, where it sits ahead of the button in reading order for pointer, keyboard, and screen reader users alike. Gate it on canEdit alone so an in-flight request, which is self-evident and momentary, does not trigger it. ReadOnly and EnabledReadOnly asserted only that the button was disabled, which encoded the gap as expected behavior. They now assert the explanation, and Updating asserts its absence. --- .../DynamicClientRegistrationSetting.stories.tsx | 13 +++++++++++++ .../DynamicClientRegistrationSetting.tsx | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index 088b0052e3b..de4c9ce293f 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -46,6 +46,11 @@ export const ReadOnly: Story = { const canvas = within(canvasElement); await expect(canvas.getByRole("button", { name: "Enable" })).toBeDisabled(); + // A disabled button is skipped by Tab and fires no pointer events, so the + // reason has to be readable on the page rather than attached to it. + await expect( + canvas.getByText(/permission to edit deployment configuration/), + ).toBeVisible(); }, }; @@ -60,6 +65,9 @@ export const EnabledReadOnly: Story = { await expect( canvas.getByRole("button", { name: "Disable" }), ).toBeDisabled(); + await expect( + canvas.getByText(/permission to edit deployment configuration/), + ).toBeVisible(); }, }; @@ -111,6 +119,11 @@ export const Updating: Story = { const canvas = within(canvasElement); await expect(canvas.getByRole("button", { name: "Enable" })).toBeDisabled(); + // Disabled mid-request is self-evident and momentary. Only a permission + // problem earns an explanation. + await expect( + canvas.queryByText(/permission to edit deployment configuration/), + ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index ebe9da19200..ab883b7c89d 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -41,6 +41,17 @@ export const DynamicClientRegistrationSetting: FC< Allow OAuth2 clients to register themselves against this deployment without prior administrator approval (RFC 7591).

+ {/* + * A disabled button takes no focus and no pointer events, so it + * cannot carry the reason it is disabled. Stating the reason here + * puts it in reading order ahead of the button for everyone. + */} + {!canEdit && ( +

+ You need permission to edit deployment configuration to change + this setting. +

+ )}
{enabled ? ( From c068c40b9a0928f5a3ac741406c7a554d42bc345 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 30 Jul 2026 16:21:44 -0700 Subject: [PATCH 13/32] fix(site): say what disabling DCR does not undo Disabling only gates the registration endpoint. The flag is read in the registration handler and in discovery metadata, and nowhere in the token or authorize path, and the settings update deletes no apps, secrets, or tokens. Clients that self-registered keep their credentials, their issued tokens, and their RFC 7592 registration access token, so a button labelled Disable implied containment it does not provide. The CLI and the docs both stated this. The web UI did not, and its disable path has no dialog to state it in, so the caveat now lives in the always-visible description. It is deliberately not gated on the current value: an admin who has just disabled needs it as much as one deciding whether to, and that is the moment they are most likely to believe the deployment is closed. Point at the Applications tab, where those clients can be removed. Registration inserts into oauth2_provider_apps and the list query is unfiltered, so they appear there. Assert the sentence in both Disabled and Enabled so a later change cannot quietly make it conditional. --- .../DynamicClientRegistrationSetting.stories.tsx | 8 ++++++++ .../DynamicClientRegistrationSetting.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index de4c9ce293f..b41a774e0ed 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -23,6 +23,11 @@ export const Disabled: Story = { await expect(canvas.getByRole("button", { name: "Enable" })).toBeEnabled(); await expect(canvas.queryByText("Enabled")).not.toBeInTheDocument(); + // Stated in both states. An admin who has already disabled still needs to + // know that clients registered earlier were not revoked. + await expect( + canvas.getByText(/keep working until you remove them/), + ).toBeVisible(); }, }; @@ -35,6 +40,9 @@ export const Enabled: Story = { await expect(canvas.getByText("Enabled")).toBeVisible(); await expect(canvas.getByRole("button", { name: "Disable" })).toBeVisible(); + await expect( + canvas.getByText(/keep working until you remove them/), + ).toBeVisible(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index ab883b7c89d..0ff859949f3 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -37,9 +37,16 @@ export const DynamicClientRegistrationSetting: FC< )} + {/* + * Disabling only gates the registration endpoint. It deletes no apps, + * secrets, or tokens, so the caveat stays visible in both states: an + * admin who has just disabled needs it as much as one deciding to. + */}

Allow OAuth2 clients to register themselves against this deployment - without prior administrator approval (RFC 7591). + without prior administrator approval (RFC 7591). Disabling stops new + registrations. Clients that already registered keep working until + you remove them from the Applications tab.

{/* * A disabled button takes no focus and no pointer events, so it From c2158c731cf1fc1d120a52e8844eba4ec10ade8a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 30 Jul 2026 17:48:09 -0700 Subject: [PATCH 14/32] fix(site): keep the DCR button focusable while a request is in flight Button renders a native button and disabled is the real attribute, so disabling the element that currently has focus made the browser blur it. A keyboard user who pressed Enter on Disable lost their place: focus fell to body for the length of the request, and the next Tab restarted from the top of the document. The enable path had the same problem one step later, where the dialog restored focus to a trigger that was disabled by the time it closed. Lacking permission and having a request in flight are different kinds of unavailable, so they no longer share a mechanism. Permission is permanent and the control is genuinely unavailable, so it keeps the native attribute and the visible explanation added earlier. A request is momentary and the button is where focus already is, so it goes inert via aria-disabled with an early return in onClick, keeping the element in the tab order. The spinner remains the visual cue, since aria-disabled draws no styling from Button. The two Button branches collapse into one. Keeping them apart would duplicate disabled, aria-disabled, className, and the onClick guard across both. Also assert that cancelling closes the confirmation dialog. CancelEnable asserted only that onChange had not been called, but onChange is unreachable from a cancel click, so it held for every implementation of onClose including one that did nothing. The cancel wiring was the only thing that story existed to protect. --- ...namicClientRegistrationSetting.stories.tsx | 79 +++++++++++++++++-- .../DynamicClientRegistrationSetting.tsx | 44 ++++++----- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index b41a774e0ed..c683e08ca4c 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -113,8 +113,16 @@ export const CancelEnable: Story = { await userEvent.click(canvas.getByRole("button", { name: "Enable" })); const body = within(canvasElement.ownerDocument.body); + const title = "Enable Dynamic Client Registration?"; + await waitFor(() => expect(body.getByText(title)).toBeVisible()); await userEvent.click(body.getByRole("button", { name: "Cancel" })); + // Cancelling closing the dialog is the only thing this story protects. + // `onChange` is unreachable from a cancel click, so asserting it was not + // called would hold even against an `onClose` that does nothing. + await waitFor(() => + expect(body.queryByText(title)).not.toBeInTheDocument(), + ); await expect(args.onChange).not.toHaveBeenCalled(); }, }; @@ -123,10 +131,18 @@ export const Updating: Story = { args: { isUpdating: true, }, - play: async ({ canvasElement }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "Enable" }); + + // Inert but still focusable, unlike the read-only case: an in-flight + // request must not blur the element the admin is standing on. + await expect(button).toHaveAttribute("aria-disabled", "true"); + button.focus(); + await expect(button).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect(args.onChange).not.toHaveBeenCalled(); - await expect(canvas.getByRole("button", { name: "Enable" })).toBeDisabled(); // Disabled mid-request is self-evident and momentary. Only a permission // problem earns an explanation. await expect( @@ -140,12 +156,65 @@ export const UpdatingWhileEnabled: Story = { enabled: true, isUpdating: true, }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "Disable" }); + + await expect(button).toHaveAttribute("aria-disabled", "true"); + button.focus(); + await expect(button).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + +/** + * Flipping the setting must not cost a keyboard user their place. The button + * goes inert while the request is in flight rather than disabled, so focus + * stays on it through the transition and the label change. + */ +export const KeepsFocusWhileUpdating: Story = { + args: { + enabled: true, + }, + render: function Harness(args) { + const [enabled, setEnabled] = useState(true); + const [isUpdating, setIsUpdating] = useState(false); + + return ( + { + setIsUpdating(true); + // Stands in for the mutation round trip, which is the window where + // a natively disabled button would blur. + setTimeout(() => { + setEnabled(next); + setIsUpdating(false); + }, 50); + }} + /> + ); + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: "Disable" }); - await expect( - canvas.getByRole("button", { name: "Disable" }), - ).toBeDisabled(); + button.focus(); + await userEvent.keyboard("{Enter}"); + + // Mid-request. A `disabled` attribute here would have blurred to . + await expect(button).toHaveAttribute("aria-disabled", "true"); + await expect(button).toHaveFocus(); + + // The same element becomes the opposite action once the request lands, and + // focus rides along rather than resetting to the top of the document. + await waitFor(() => + expect(canvas.getByRole("button", { name: "Enable" })).toBeVisible(), + ); + await expect(button).toHaveFocus(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 0ff859949f3..07b082de2e7 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -61,24 +61,32 @@ export const DynamicClientRegistrationSetting: FC< )} - {enabled ? ( - - ) : ( - - )} + {/* + * Lacking permission is permanent, so the button is genuinely + * unavailable and takes the native attribute. An in-flight request is + * momentary and the button is where focus already is, so it goes inert + * without leaving the tab order: disabling a focused element blurs it, + * which drops a keyboard user back to the top of the document mid-flip. + */} + Date: Thu, 30 Jul 2026 20:40:31 -0700 Subject: [PATCH 15/32] docs: add the web UI route to the DCR section The section enumerated the CLI and the management API as the ways to change the setting, which read as exhaustive. There is now a third route, and it is the one an admin reaches for first, so it goes first. The same file already documents a web UI route for creating applications, which left the Dynamic Client Registration section as the only place that omitted one. --- docs/admin/integrations/oauth2-provider.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 4411b17c58b..1a37cf14566 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -71,6 +71,14 @@ curl -X POST \ Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) lets a client register itself against `/oauth2/register` instead of an admin creating the application manually. It's **disabled by default**; an owner must turn it on before any client can self-register. +Change the setting in the web UI: + +1. Navigate to **Deployment Settings** → **OAuth2 Applications** +2. Open the **Settings** tab +3. Select **Enable** or **Disable** next to **Dynamic Client Registration** + +Enabling asks for confirmation. Disabling takes effect immediately. + Check or change the setting with the CLI: ```sh From 0560e77f8820d24e3f773bd508cd7cb7f8e21ce1 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 30 Jul 2026 21:16:51 -0700 Subject: [PATCH 16/32] fix(site): scope the settings error to the settings tab The view uses one `error` prop for two jobs: the page-level alert and the gate on the applications empty state. That gate reads "if the apps request failed, do not claim there are no applications", so it only holds while the prop carries the apps error, which is what it carried before this branch. Folding the settings query and mutation errors into it meant a settings failure rendered the applications table as a blank body, with no empty-state message and no call to action, on a deployment whose apps request had succeeded. The `??` chain also let the apps error shadow the mutation error, so a failed update was invisible while the list was broken. The settings errors now travel in their own prop and render inside the settings tab. The tab previously distinguished only loading from loaded: on failure it rendered nothing at all, since the value is undefined in both the failed and the omitted case, and the field is optional on the wire. It now shows the error, or a short message when the value is missing without one, and the setting stays on screen behind a failed update so the current value is still readable. Drop the permission check on the settings TabsContent. `activeTab` already falls back to applications without `canViewSettings`, and TabsContent renders nothing unless its value matches, so the check could never be observed and only suggested the fallback was untrustworthy. --- .../OAuth2AppsSettingsPage.tsx | 8 ++- .../OAuth2AppsSettingsPageView.stories.tsx | 65 +++++++++++++++++++ .../OAuth2AppsSettingsPageView.tsx | 49 ++++++++++---- 3 files changed, 106 insertions(+), 16 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx index 2505998ff43..3474785bf11 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx @@ -32,12 +32,14 @@ const OAuth2AppsSettingsPage: FC = () => { = { canCreateApp: true, canViewSettings: true, canEditSettings: true, + settingsError: undefined, isLoadingSettings: false, isUpdatingSettings: false, dynamicClientRegistrationEnabled: false, @@ -88,6 +89,70 @@ export const SettingsTabHiddenWithoutPermission: Story = { }, }; +/** + * A settings failure is scoped to the settings tab. The applications empty + * state still renders, because the apps request succeeded and only the `error` + * prop gates that message. + */ +export const SettingsFetchErrorKeepsAppsEmptyState: Story = { + args: { + isLoading: false, + apps: [], + settingsError: "settings boom", + dynamicClientRegistrationEnabled: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText("No OAuth2 applications configured"), + ).toBeVisible(); + + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + await expect(canvas.getByText("settings boom")).toBeVisible(); + await expect( + canvas.queryByRole("button", { name: "Enable" }), + ).not.toBeInTheDocument(); + }, +}; + +/** + * A failed update leaves the setting on screen with the error above it, so the + * admin can see the current value and retry. + */ +export const SettingsUpdateErrorKeepsSettingVisible: Story = { + args: { + isLoading: false, + apps: MockOAuth2ProviderApps, + settingsError: "update boom", + dynamicClientRegistrationEnabled: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect(canvas.getByText("update boom")).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Enable" })).toBeVisible(); + }, +}; + +/** + * The value is optional on the wire. A response that omits it must not leave + * the tab silently blank. + */ +export const SettingsValueOmitted: Story = { + args: { + isLoading: false, + dynamicClientRegistrationEnabled: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect(canvas.getByText("Settings are unavailable.")).toBeVisible(); + }, +}; + export const SettingsTabFromUrl: Story = { parameters: { reactRouter: reactRouterParameters({ diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index d90209aef48..f5075a95a80 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -39,6 +39,7 @@ type OAuth2AppsSettingsProps = { canCreateApp: boolean; canViewSettings: boolean; canEditSettings: boolean; + settingsError: unknown; isLoadingSettings: boolean; isUpdatingSettings: boolean; dynamicClientRegistrationEnabled: boolean | undefined; @@ -61,6 +62,7 @@ const OAuth2AppsSettingsPageView: FC = ({ canCreateApp, canViewSettings, canEditSettings, + settingsError, isLoadingSettings, isUpdatingSettings, dynamicClientRegistrationEnabled, @@ -129,19 +131,40 @@ const OAuth2AppsSettingsPageView: FC = ({ - {canViewSettings && ( - - {isLoadingSettings && } - {dynamicClientRegistrationEnabled !== undefined && ( - - )} - - )} + {/* + * No permission check here. `activeTab` above already falls back to + * applications without `canViewSettings`, and TabsContent renders + * nothing unless its value matches, so a check here could never be + * observed and only implies the fallback is untrustworthy. + */} + + {isLoadingSettings ? ( + + ) : ( +
+ {Boolean(settingsError) && } + {dynamicClientRegistrationEnabled !== undefined && ( + + )} + {/* + * The value is optional on the wire, so a response that omits + * it would otherwise leave this tab blank with nothing to + * explain why. + */} + {!settingsError && + dynamicClientRegistrationEnabled === undefined && ( +

+ Settings are unavailable. +

+ )} +
+ )} +
); From 754b1a6211c798f31eacd7ffe5867ff6c0ce449e Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 31 Jul 2026 09:01:29 -0700 Subject: [PATCH 17/32] fix(site): follow the ConfirmDialog move to components/Dialog Upstream renamed components/Dialogs to components/Dialog and rebuilt the dialog on radix-ui. The rename touched every existing import, but this branch added a new file after that point, so the merge left one dead path behind without a conflict to signal it. Radix makes the modal behave differently in two ways the external-change story depended on. It sets pointer-events: none on the body while open, which no z-index defeats, and its dismissable layer treats a pointer interaction anywhere outside the content as a dismiss rather than only on a backdrop node. Clicking a control mid-dialog therefore closes the very dialog under test. Arm the external change on a timer before opening instead, so nothing outside the dialog is ever clicked. --- ...namicClientRegistrationSetting.stories.tsx | 68 +++++++++---------- .../DynamicClientRegistrationSetting.tsx | 2 +- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index c683e08ca4c..cfb7d79c0a4 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -224,8 +224,9 @@ export const KeepsFocusWhileUpdating: Story = { * open, the dialog stays put and the admin closes it themselves. It must * never open, close, or reopen on its own as `enabled` changes underneath. * - * The external-change buttons stack above the dialog's backdrop so they stay - * clickable while it is open. + * The external change is armed on a timer rather than driven by a control + * clicked mid-dialog. The dialog is modal, so any pointer interaction outside + * it dismisses it, which would destroy the state under test. */ export const SurvivesExternalEnabledChanges: Story = { render: function Harness(args) { @@ -233,14 +234,20 @@ export const SurvivesExternalEnabledChanges: Story = { return (
-
- - -
+ + + new Promise((resolve) => setTimeout(resolve, 400)); - // Grab these before opening the dialog. MUI's modal marks everything - // outside itself aria-hidden, and role queries skip aria-hidden nodes, - // so a lookup by role after the dialog opens will not find them. - const externalEnable = canvas.getByRole("button", { - name: "Simulate external enable", - }); - const externalDisable = canvas.getByRole("button", { - name: "Simulate external disable", - }); - + await userEvent.click( + canvas.getByRole("button", { name: "Arm external enable" }), + ); await userEvent.click(canvas.getByRole("button", { name: "Enable" })); await waitFor(() => expect(body.getByText(title)).toBeVisible()); const dialog = body.getByTestId("dialog"); - // Enabled elsewhere. The dialog ignores it: the admin's intent to - // confirm is theirs to resolve, not the server's. - await userEvent.click(externalEnable); - await expect(canvas.getByText("Enabled")).toBeVisible(); + // The armed change lands here. The dialog ignores it: the admin's intent + // to confirm is theirs to resolve, not the server's. await settleTransition(); await expect(body.getByText(title)).toBeVisible(); // Still the same node, so it was never torn down and rebuilt. await expect(body.getByTestId("dialog")).toBe(dialog); - // And disabled again, the transition that used to resurrect it. - await userEvent.click(externalDisable); - await settleTransition(); - await expect(body.getByTestId("dialog")).toBe(dialog); - + // Cancelling is the admin's own action, so it closes. await userEvent.click(body.getByRole("button", { name: "Cancel" })); await waitFor(() => expect(body.queryByText(title)).not.toBeInTheDocument(), ); - // Once the admin has closed it, no amount of external churn brings it - // back. - await userEvent.click(externalEnable); - await userEvent.click(externalDisable); + // Going back to disabled is the transition that used to resurrect it. + await userEvent.click( + canvas.getByRole("button", { name: "Set externally disabled" }), + ); await settleTransition(); await expect(body.queryByText(title)).not.toBeInTheDocument(); await expect(args.onChange).not.toHaveBeenCalled(); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 07b082de2e7..8c5ce7da063 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -1,7 +1,7 @@ import { type FC, useId, useState } from "react"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; -import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; import { Spinner } from "#/components/Spinner/Spinner"; type DynamicClientRegistrationSettingProps = { From 90292cd5cdb046f3dfe6082ac518c0db1b899925 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 31 Jul 2026 09:41:28 -0700 Subject: [PATCH 18/32] fix(site): group the settings props and scope the header action The view took eleven props, seven of them for one boolean setting, and the caller had to keep them mutually consistent by hand. A viewer who could not read deployment config was a boolean the caller cross-checked against five other values, so combinations the page can never produce still typechecked and rendered: no view permission alongside a defined value and an edit permission, or loading alongside a defined value. The settings values now travel as one optional object, so "cannot view" is the absence of the prop rather than a flag beside the values it governs. Those combinations are unrepresentable rather than merely undocumented, which matters more once a second setting lands on this tab. The guard on the settings TabsContent is back, but as the narrowing TypeScript requires rather than a permission check that could not fire. The header sits outside the tabs and rendered its action unconditionally, so "Add application" appeared while the settings tab was open, promising to act on the settings below it and then navigating away. Gate it on the active tab, and widen the description, which described only the applications half. The title stays as it is, since changing it would also mean changing the sidebar label and the browser title. --- .../OAuth2AppsSettingsPage.tsx | 28 +++-- .../OAuth2AppsSettingsPageView.stories.tsx | 80 +++++++++--- .../OAuth2AppsSettingsPageView.tsx | 116 +++++++++--------- 3 files changed, 137 insertions(+), 87 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx index 3474785bf11..554530596a8 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx @@ -37,19 +37,23 @@ const OAuth2AppsSettingsPage: FC = () => { // unknown when it loaded fine. error={appsQuery.error} canCreateApp={canCreateApp} - canViewSettings={canViewSettings} - canEditSettings={canEditSettings} - settingsError={settingsQuery.error ?? updateSettingsMutation.error} - isLoadingSettings={settingsQuery.isLoading} - isUpdatingSettings={updateSettingsMutation.isPending} - dynamicClientRegistrationEnabled={ - settingsQuery.data?.dynamic_client_registration_enabled + settings={ + canViewSettings + ? { + canEdit: canEditSettings, + isLoading: settingsQuery.isLoading, + isUpdating: updateSettingsMutation.isPending, + error: settingsQuery.error ?? updateSettingsMutation.error, + dynamicClientRegistrationEnabled: + settingsQuery.data?.dynamic_client_registration_enabled, + onDynamicClientRegistrationChange: (enabled) => { + updateSettingsMutation.mutate({ + dynamic_client_registration_enabled: enabled, + }); + }, + } + : undefined } - onDynamicClientRegistrationChange={(enabled) => { - updateSettingsMutation.mutate({ - dynamic_client_registration_enabled: enabled, - }); - }} /> ); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index 4e46ae60bdd..51f03d2706b 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -4,18 +4,23 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { MockOAuth2ProviderApps } from "#/testHelpers/entities"; import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView"; +// Spread and override per story. Omitting `settings` entirely is how a viewer +// without deployment config read access is expressed. +const MockSettingsTab = { + canEdit: true, + isLoading: false, + isUpdating: false, + error: undefined, + dynamicClientRegistrationEnabled: false, + onDynamicClientRegistrationChange: fn(), +}; + const meta: Meta = { title: "pages/DeploymentSettingsPage/OAuth2AppsSettingsPageView", component: OAuth2AppsSettingsPageView, args: { canCreateApp: true, - canViewSettings: true, - canEditSettings: true, - settingsError: undefined, - isLoadingSettings: false, - isUpdatingSettings: false, - dynamicClientRegistrationEnabled: false, - onDynamicClientRegistrationChange: fn(), + settings: MockSettingsTab, }, }; export default meta; @@ -60,22 +65,22 @@ export const NoCreatePermissions: Story = { // through, and clicking it proves the change handler is connected. export const SettingsTabRendersDynamicClientRegistration: Story = { args: { - dynamicClientRegistrationEnabled: true, + settings: { ...MockSettingsTab, dynamicClientRegistrationEnabled: true }, }, play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); await userEvent.click(canvas.getByRole("button", { name: "Disable" })); - await expect(args.onDynamicClientRegistrationChange).toHaveBeenCalledWith( - false, - ); + await expect( + args.settings?.onDynamicClientRegistrationChange, + ).toHaveBeenCalledWith(false); }, }; export const SettingsTabHiddenWithoutPermission: Story = { args: { - canViewSettings: false, + settings: undefined, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -89,6 +94,33 @@ export const SettingsTabHiddenWithoutPermission: Story = { }, }; +/** + * The header sits outside the tabs, so its action is scoped to the tab it + * belongs to. Offering "Add application" while the settings tab is open would + * promise to act on the settings below it and then navigate away. + */ +export const AddApplicationIsScopedToApplicationsTab: Story = { + args: { + isLoading: false, + apps: MockOAuth2ProviderApps, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = canvas.getByRole("link", { name: "Add application" }); + await expect(header).toBeVisible(); + + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + await expect( + canvas.queryByRole("link", { name: "Add application" }), + ).not.toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("tab", { name: "Applications" })); + await expect( + canvas.getByRole("link", { name: "Add application" }), + ).toBeVisible(); + }, +}; + /** * A settings failure is scoped to the settings tab. The applications empty * state still renders, because the apps request succeeded and only the `error` @@ -98,8 +130,11 @@ export const SettingsFetchErrorKeepsAppsEmptyState: Story = { args: { isLoading: false, apps: [], - settingsError: "settings boom", - dynamicClientRegistrationEnabled: undefined, + settings: { + ...MockSettingsTab, + error: "settings boom", + dynamicClientRegistrationEnabled: undefined, + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -124,8 +159,7 @@ export const SettingsUpdateErrorKeepsSettingVisible: Story = { args: { isLoading: false, apps: MockOAuth2ProviderApps, - settingsError: "update boom", - dynamicClientRegistrationEnabled: false, + settings: { ...MockSettingsTab, error: "update boom" }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -143,7 +177,10 @@ export const SettingsUpdateErrorKeepsSettingVisible: Story = { export const SettingsValueOmitted: Story = { args: { isLoading: false, - dynamicClientRegistrationEnabled: undefined, + settings: { + ...MockSettingsTab, + dynamicClientRegistrationEnabled: undefined, + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -174,7 +211,7 @@ export const SettingsTabFromUrl: Story = { // tab selected at all. export const UnpermittedTabFromUrlFallsBack: Story = { args: { - canViewSettings: false, + settings: undefined, }, parameters: { reactRouter: reactRouterParameters({ @@ -194,8 +231,11 @@ export const UnpermittedTabFromUrlFallsBack: Story = { // once the settings request resolves. export const SettingsTabLoading: Story = { args: { - isLoadingSettings: true, - dynamicClientRegistrationEnabled: undefined, + settings: { + ...MockSettingsTab, + isLoading: true, + dynamicClientRegistrationEnabled: undefined, + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index f5075a95a80..e1a4ba6a57b 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -32,18 +32,26 @@ import { useClickableTableRow } from "#/hooks/useClickableTableRow"; import { useSearchParamsKey } from "#/hooks/useSearchParamsKey"; import { DynamicClientRegistrationSetting } from "./DynamicClientRegistrationSetting"; +/** + * Absent when the viewer cannot read deployment config, so "cannot view" is + * the shape of the prop rather than a flag the caller has to keep consistent + * with the five values beside it. + */ +type SettingsTab = { + canEdit: boolean; + isLoading: boolean; + isUpdating: boolean; + error: unknown; + dynamicClientRegistrationEnabled: boolean | undefined; + onDynamicClientRegistrationChange: (enabled: boolean) => void; +}; + type OAuth2AppsSettingsProps = { apps?: TypesGen.OAuth2ProviderApp[]; isLoading: boolean; error: unknown; canCreateApp: boolean; - canViewSettings: boolean; - canEditSettings: boolean; - settingsError: unknown; - isLoadingSettings: boolean; - isUpdatingSettings: boolean; - dynamicClientRegistrationEnabled: boolean | undefined; - onDynamicClientRegistrationChange: (enabled: boolean) => void; + settings?: SettingsTab; }; const AddApplicationButton: FC = () => ( @@ -60,13 +68,7 @@ const OAuth2AppsSettingsPageView: FC = ({ isLoading, error, canCreateApp, - canViewSettings, - canEditSettings, - settingsError, - isLoadingSettings, - isUpdatingSettings, - dynamicClientRegistrationEnabled, - onDynamicClientRegistrationChange, + settings, }) => { const tabState = useSearchParamsKey({ key: "tab", @@ -75,18 +77,26 @@ const OAuth2AppsSettingsPageView: FC = ({ // Unknown values, and the settings tab for users who cannot view it, fall // back to the applications tab rather than selecting nothing. const activeTab = - tabState.value === "settings" && canViewSettings - ? "settings" - : "applications"; + tabState.value === "settings" && settings ? "settings" : "applications"; return (
+ {/* + * The header sits outside the tabs, so a tab-specific action here would + * promise to act on content it navigates away from. Adding an + * application belongs to the applications tab alone. + */} : undefined} + actions={ + canCreateApp && activeTab === "applications" ? ( + + ) : undefined + } > OAuth2 applications - Configure applications to use Coder as an OAuth2 provider. + Register applications to use Coder as an OAuth2 provider, and + configure how this deployment behaves as one. @@ -99,9 +109,7 @@ const OAuth2AppsSettingsPageView: FC = ({ Applications - {canViewSettings && ( - Settings - )} + {settings && Settings} @@ -131,40 +139,38 @@ const OAuth2AppsSettingsPageView: FC = ({ - {/* - * No permission check here. `activeTab` above already falls back to - * applications without `canViewSettings`, and TabsContent renders - * nothing unless its value matches, so a check here could never be - * observed and only implies the fallback is untrustworthy. - */} - - {isLoadingSettings ? ( - - ) : ( -
- {Boolean(settingsError) && } - {dynamicClientRegistrationEnabled !== undefined && ( - - )} - {/* - * The value is optional on the wire, so a response that omits - * it would otherwise leave this tab blank with nothing to - * explain why. - */} - {!settingsError && - dynamicClientRegistrationEnabled === undefined && ( -

- Settings are unavailable. -

+ {settings && ( + + {settings.isLoading ? ( + + ) : ( +
+ {Boolean(settings.error) && ( + )} -
- )} -
+ {settings.dynamicClientRegistrationEnabled !== undefined && ( + + )} + {/* + * The value is optional on the wire, so a response that omits + * it would otherwise leave this tab blank with nothing to + * explain why. + */} + {!settings.error && + settings.dynamicClientRegistrationEnabled === undefined && ( +

+ Settings are unavailable. +

+ )} +
+ )} +
+ )}
); From 0ea58904d024bf709c5589811ccbca38f7989f53 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 31 Jul 2026 10:43:03 -0700 Subject: [PATCH 19/32] test(site): assert the settings invalidation spares app queries `invalidateQueries` matches by key prefix, so asserting the settings key was invalidated said nothing about what else went with it. Seed an app query alongside it and assert it survives. Widening the invalidation to the `oauth2-provider` prefix, which would refetch every app on every settings save, previously passed this suite untouched. Keep the literal key assertion rather than treating it as redundant. The two guard different drift: the literal assertion catches a change to the key definition, and the new seed catches a change to the invalidation's scope. Neither subsumes the other. Import the shared `createTestQueryClient` and drop the local copy, which matched it except for `gcTime`, giving this suite different cache eviction semantics than every suite using the shared helper for no stated reason. --- site/src/api/queries/oauth2.test.ts | 39 ++++++++++++++--------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/site/src/api/queries/oauth2.test.ts b/site/src/api/queries/oauth2.test.ts index 63d0164df9f..2edc29682b2 100644 --- a/site/src/api/queries/oauth2.test.ts +++ b/site/src/api/queries/oauth2.test.ts @@ -1,8 +1,12 @@ -import { QueryClient } from "react-query"; import { describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; -import { getOAuth2ProviderSettings, putOAuth2ProviderSettings } from "./oauth2"; +import { createTestQueryClient } from "#/testHelpers/renderHelpers"; +import { + getOAuth2ProviderSettings, + oauth2ProviderAppKey, + putOAuth2ProviderSettings, +} from "./oauth2"; vi.mock("#/api/api", () => ({ API: { @@ -11,18 +15,6 @@ vi.mock("#/api/api", () => ({ }, })); -const createTestQueryClient = (): QueryClient => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: Number.POSITIVE_INFINITY, - refetchOnWindowFocus: false, - networkMode: "offlineFirst", - }, - }, - }); - const settings: TypesGen.OAuth2ProviderSettings = { dynamic_client_registration_enabled: true, }; @@ -59,17 +51,24 @@ describe("putOAuth2ProviderSettings", () => { expect(result).toEqual(settings); }); - it("invalidates the settings query on a successful update", async () => { + // `invalidateQueries` matches by key prefix, so asserting the settings key + // was invalidated says nothing about what else went with it. Seeding an app + // query alongside it is what catches a widened invalidation scope, which + // would refetch every app on every settings save. + it("invalidates the settings query without touching app queries", async () => { const queryClient = createTestQueryClient(); - queryClient.setQueryData(getOAuth2ProviderSettings().queryKey, { + const settingsQueryKey = getOAuth2ProviderSettings().queryKey; + const appQueryKey = oauth2ProviderAppKey("app-1"); + queryClient.setQueryData(settingsQueryKey, { dynamic_client_registration_enabled: false, }); + queryClient.setQueryData(appQueryKey, { id: "app-1" }); await putOAuth2ProviderSettings(queryClient).onSuccess(); - expect( - queryClient.getQueryState(getOAuth2ProviderSettings().queryKey) - ?.isInvalidated, - ).toBe(true); + expect(queryClient.getQueryState(settingsQueryKey)?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(appQueryKey)?.isInvalidated).toBe(false); }); }); From 6bca8bef9ae55ce0751d43e7b3bb8335f8e476f5 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 31 Jul 2026 13:42:29 -0700 Subject: [PATCH 20/32] fix(site): address review nits on the DCR setting Six small items from review, each independent: The section heading was an `h3` directly under the page `h1`, skipping a level; the repo's own section primitive uses `h2` in this position and the class list already sets the visual size, so nothing moves. `this.axios.get` and `.put` without a type parameter left `resp.data` as `any`, which the declared return type then laundered. Both are typed now. `hideCancel={false}` restated the `delete` default. The confirm button already carries `data-testid="confirm-button"`, which is what it exists for: the button and the trigger behind it share an accessible name. Using it removes the scoping through the dialog node and the comment explaining why the scope was needed. `UnpermittedTabFromUrlFallsBack` asserted which tab was highlighted but not that the settings control was absent, so a later `forceMount` on inactive tab content would not have failed it. The story title dropped the owning page directory, unlike sibling components nested in a page folder, so it did not group with the view it belongs to. --- site/src/api/api.ts | 9 +++++++-- .../DynamicClientRegistrationSetting.stories.tsx | 13 +++++-------- .../DynamicClientRegistrationSetting.tsx | 5 ++--- .../OAuth2AppsSettingsPageView.stories.tsx | 6 ++++++ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c6d5e0692a8..c7de79f98a7 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2037,14 +2037,19 @@ class ApiMethods { getOAuth2ProviderSettings = async (): Promise => { - const resp = await this.axios.get("/api/v2/oauth2-provider/settings"); + const resp = await this.axios.get( + "/api/v2/oauth2-provider/settings", + ); return resp.data; }; putOAuth2ProviderSettings = async ( data: TypesGen.OAuth2ProviderSettings, ): Promise => { - const resp = await this.axios.put("/api/v2/oauth2-provider/settings", data); + const resp = await this.axios.put( + "/api/v2/oauth2-provider/settings", + data, + ); return resp.data; }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index cfb7d79c0a4..39a5df26e6f 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -4,7 +4,8 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { DynamicClientRegistrationSetting } from "./DynamicClientRegistrationSetting"; const meta: Meta = { - title: "pages/DeploymentSettingsPage/DynamicClientRegistrationSetting", + title: + "pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting", component: DynamicClientRegistrationSetting, args: { enabled: false, @@ -88,21 +89,17 @@ export const EnableShowsConfirmationDialog: Story = { await body.findByText("Enable Dynamic Client Registration?"); await expect(args.onChange).not.toHaveBeenCalled(); - // The dialog's confirm button shares its accessible name with the trigger - // button behind it, so scope the query to the dialog. - const dialog = within(body.getByTestId("dialog")); - // The dialog covers the section description, so it is the last thing the // admin reads before enabling. It has to name both what enabling exposes // and what disabling does not undo, or the confirm click decides nothing. await expect( - dialog.getByText(/no Coder account and no administrator approval/), + body.getByText(/no Coder account and no administrator approval/), ).toBeInTheDocument(); await expect( - dialog.getByText(/does not revoke clients that already registered/), + body.getByText(/does not revoke clients that already registered/), ).toBeInTheDocument(); - await userEvent.click(dialog.getByRole("button", { name: "Enable" })); + await userEvent.click(body.getByTestId("confirm-button")); await expect(args.onChange).toHaveBeenCalledWith(true); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 8c5ce7da063..72d14acfdd6 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -25,12 +25,12 @@ export const DynamicClientRegistrationSetting: FC< >
-

Dynamic Client Registration -

+ {enabled && ( Enabled @@ -91,7 +91,6 @@ export const DynamicClientRegistrationSetting: FC< { setIsEnableDialogOpen(false); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index 51f03d2706b..d43f28c31fb 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -224,6 +224,12 @@ export const UnpermittedTabFromUrlFallsBack: Story = { await expect( canvas.getByRole("tab", { name: "Applications" }), ).toHaveAttribute("aria-selected", "true"); + // Which tab is highlighted says nothing about what rendered. Radix mounts + // no inactive TabsContent today, so a later forceMount would otherwise + // hand this user a control the deep link should not have reached. + await expect( + canvas.queryByRole("button", { name: "Enable" }), + ).not.toBeInTheDocument(); }, }; From 3ededdcf9a432dd9073dfeb4bc89976171bbe70e Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 31 Jul 2026 14:08:25 -0700 Subject: [PATCH 21/32] refactor(site): follow module naming and trim restating comments Every other factory in the oauth2 queries module drops the transport prefix the module name already carries: getApps, getApp, postApp, putApp, getAppSecrets, revokeApp, getGitHubDevice. The settings pair was byte-identical to the API methods it wraps, so a call site could not tell whether it held a query descriptor or a promise. The test paid for it directly, holding both one qualifier apart in one file. The key inverted the same rule the other way. The app keys carry the prefix and derive the nested ones off the parent, while the settings key retyped the "oauth2-provider" literal and named nothing about what it keyed. Both now derive from a shared prefix constant, so the literal is written once. Two names that read as their opposite: the story exercising the click-to-disable path sat one character from the disabled-state story, and the view's unqualified isLoading was the apps query while the settings object carries its own. Six comments restated the code beneath them before reaching the fact a reader could not get from the source. Two of those were added later in this branch, not in the original diff. --- site/src/api/queries/oauth2.test.ts | 24 +++++---------- site/src/api/queries/oauth2.ts | 13 +++++---- ...namicClientRegistrationSetting.stories.tsx | 3 +- .../OAuth2AppsSettingsPage.tsx | 20 +++++-------- .../OAuth2AppsSettingsPageView.stories.tsx | 29 ++++++++----------- .../OAuth2AppsSettingsPageView.tsx | 12 ++++---- 6 files changed, 40 insertions(+), 61 deletions(-) diff --git a/site/src/api/queries/oauth2.test.ts b/site/src/api/queries/oauth2.test.ts index 2edc29682b2..3d8a8352212 100644 --- a/site/src/api/queries/oauth2.test.ts +++ b/site/src/api/queries/oauth2.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import { createTestQueryClient } from "#/testHelpers/renderHelpers"; -import { - getOAuth2ProviderSettings, - oauth2ProviderAppKey, - putOAuth2ProviderSettings, -} from "./oauth2"; +import { getSettings, oauth2ProviderAppKey, putSettings } from "./oauth2"; vi.mock("#/api/api", () => ({ API: { @@ -19,33 +15,29 @@ const settings: TypesGen.OAuth2ProviderSettings = { dynamic_client_registration_enabled: true, }; -describe("getOAuth2ProviderSettings", () => { +describe("getSettings", () => { it("uses a queryKey nested under the oauth2-provider prefix", () => { - expect(getOAuth2ProviderSettings().queryKey).toEqual([ - "oauth2-provider", - "settings", - ]); + expect(getSettings().queryKey).toEqual(["oauth2-provider", "settings"]); }); it("fetches settings via the API client", async () => { const getSettingsMock = vi.mocked(API.getOAuth2ProviderSettings); getSettingsMock.mockResolvedValue(settings); - const result = await getOAuth2ProviderSettings().queryFn(); + const result = await getSettings().queryFn(); expect(getSettingsMock).toHaveBeenCalled(); expect(result).toEqual(settings); }); }); -describe("putOAuth2ProviderSettings", () => { +describe("putSettings", () => { it("delegates directly to the API client", async () => { const putSettingsMock = vi.mocked(API.putOAuth2ProviderSettings); putSettingsMock.mockResolvedValue(settings); const queryClient = createTestQueryClient(); - const result = - await putOAuth2ProviderSettings(queryClient).mutationFn(settings); + const result = await putSettings(queryClient).mutationFn(settings); expect(putSettingsMock).toHaveBeenCalledWith(settings); expect(result).toEqual(settings); @@ -57,14 +49,14 @@ describe("putOAuth2ProviderSettings", () => { // would refetch every app on every settings save. it("invalidates the settings query without touching app queries", async () => { const queryClient = createTestQueryClient(); - const settingsQueryKey = getOAuth2ProviderSettings().queryKey; + const settingsQueryKey = getSettings().queryKey; const appQueryKey = oauth2ProviderAppKey("app-1"); queryClient.setQueryData(settingsQueryKey, { dynamic_client_registration_enabled: false, }); queryClient.setQueryData(appQueryKey, { id: "app-1" }); - await putOAuth2ProviderSettings(queryClient).onSuccess(); + await putSettings(queryClient).onSuccess(); expect(queryClient.getQueryState(settingsQueryKey)?.isInvalidated).toBe( true, diff --git a/site/src/api/queries/oauth2.ts b/site/src/api/queries/oauth2.ts index 72b5dccf64e..8085f970f36 100644 --- a/site/src/api/queries/oauth2.ts +++ b/site/src/api/queries/oauth2.ts @@ -2,14 +2,15 @@ import type { QueryClient } from "react-query"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; -const oauth2ProviderAppsKey = ["oauth2-provider", "apps"]; +const oauth2ProviderKey = ["oauth2-provider"]; +const oauth2ProviderAppsKey = oauth2ProviderKey.concat("apps"); export const oauth2ProviderAppKey = (appId: string) => oauth2ProviderAppsKey.concat(appId); export const oauth2ProviderAppSecretsKey = (appId: string) => oauth2ProviderAppKey(appId).concat("secrets"); const userAppsKey = (userId: string) => oauth2ProviderAppsKey.concat(userId); -const settingsKey = ["oauth2-provider", "settings"]; +const oauth2ProviderSettingsKey = oauth2ProviderKey.concat("settings"); export const getGitHubDevice = () => { return { @@ -123,19 +124,19 @@ export const revokeApp = (queryClient: QueryClient, userId: string) => { }; }; -export const getOAuth2ProviderSettings = () => { +export const getSettings = () => { return { - queryKey: settingsKey, + queryKey: oauth2ProviderSettingsKey, queryFn: () => API.getOAuth2ProviderSettings(), }; }; -export const putOAuth2ProviderSettings = (queryClient: QueryClient) => { +export const putSettings = (queryClient: QueryClient) => { return { mutationFn: API.putOAuth2ProviderSettings, onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: settingsKey, + queryKey: oauth2ProviderSettingsKey, }); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index 39a5df26e6f..cc0a58779fc 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -299,8 +299,7 @@ export const SurvivesExternalEnabledChanges: Story = { }, }; -// Disabling skips the confirmation dialog, unlike enabling. -export const Disable: Story = { +export const DisableSkipsConfirmationDialog: Story = { args: { enabled: true, }, diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx index 554530596a8..2751fbdaaf5 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx @@ -1,10 +1,6 @@ import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { - getApps, - getOAuth2ProviderSettings, - putOAuth2ProviderSettings, -} from "#/api/queries/oauth2"; +import { getApps, getSettings, putSettings } from "#/api/queries/oauth2"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { pageTitle } from "#/utils/page"; import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView"; @@ -14,12 +10,10 @@ const OAuth2AppsSettingsPage: FC = () => { const queryClient = useQueryClient(); const appsQuery = useQuery(getApps()); const settingsQuery = useQuery({ - ...getOAuth2ProviderSettings(), + ...getSettings(), enabled: permissions.viewDeploymentConfig, }); - const updateSettingsMutation = useMutation( - putOAuth2ProviderSettings(queryClient), - ); + const updateSettingsMutation = useMutation(putSettings(queryClient)); const canCreateApp = permissions.createOAuth2App; const canViewSettings = permissions.viewDeploymentConfig; @@ -31,10 +25,10 @@ const OAuth2AppsSettingsPage: FC = () => { ; export const Loading: Story = { args: { - isLoading: true, + isLoadingApps: true, }, }; export const WithError: Story = { args: { - isLoading: false, + isLoadingApps: false, error: "some error", }, }; export const Apps: Story = { args: { - isLoading: false, + isLoadingApps: false, apps: MockOAuth2ProviderApps, }, }; export const Empty: Story = { args: { - isLoading: false, + isLoadingApps: false, }, }; @@ -59,11 +59,9 @@ export const NoCreatePermissions: Story = { }, }; -// The setting's own behavior is covered by -// DynamicClientRegistrationSetting.stories.tsx. This story covers the wiring -// between the two: rendering "Disable" proves the enabled state is threaded -// through, and clicking it proves the change handler is connected. -export const SettingsTabRendersDynamicClientRegistration: Story = { +// Setting behavior is covered in DynamicClientRegistrationSetting.stories.tsx; +// this covers only the wiring. +export const SettingsTabWiresDynamicClientRegistration: Story = { args: { settings: { ...MockSettingsTab, dynamicClientRegistrationEnabled: true }, }, @@ -101,7 +99,7 @@ export const SettingsTabHiddenWithoutPermission: Story = { */ export const AddApplicationIsScopedToApplicationsTab: Story = { args: { - isLoading: false, + isLoadingApps: false, apps: MockOAuth2ProviderApps, }, play: async ({ canvasElement }) => { @@ -128,7 +126,7 @@ export const AddApplicationIsScopedToApplicationsTab: Story = { */ export const SettingsFetchErrorKeepsAppsEmptyState: Story = { args: { - isLoading: false, + isLoadingApps: false, apps: [], settings: { ...MockSettingsTab, @@ -157,7 +155,7 @@ export const SettingsFetchErrorKeepsAppsEmptyState: Story = { */ export const SettingsUpdateErrorKeepsSettingVisible: Story = { args: { - isLoading: false, + isLoadingApps: false, apps: MockOAuth2ProviderApps, settings: { ...MockSettingsTab, error: "update boom" }, }, @@ -176,7 +174,7 @@ export const SettingsUpdateErrorKeepsSettingVisible: Story = { */ export const SettingsValueOmitted: Story = { args: { - isLoading: false, + isLoadingApps: false, settings: { ...MockSettingsTab, dynamicClientRegistrationEnabled: undefined, @@ -207,8 +205,6 @@ export const SettingsTabFromUrl: Story = { }, }; -// An unpermitted deep link selects the applications tab rather than leaving no -// tab selected at all. export const UnpermittedTabFromUrlFallsBack: Story = { args: { settings: undefined, @@ -233,8 +229,7 @@ export const UnpermittedTabFromUrlFallsBack: Story = { }, }; -// The tab is present from first paint so it does not shift into the tab bar -// once the settings request resolves. +// So the tab does not pop into the tab bar when the request resolves. export const SettingsTabLoading: Story = { args: { settings: { diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index e1a4ba6a57b..3553ffee0bc 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -48,7 +48,7 @@ type SettingsTab = { type OAuth2AppsSettingsProps = { apps?: TypesGen.OAuth2ProviderApp[]; - isLoading: boolean; + isLoadingApps: boolean; error: unknown; canCreateApp: boolean; settings?: SettingsTab; @@ -65,7 +65,7 @@ const AddApplicationButton: FC = () => ( const OAuth2AppsSettingsPageView: FC = ({ apps, - isLoading, + isLoadingApps, error, canCreateApp, settings, @@ -74,8 +74,7 @@ const OAuth2AppsSettingsPageView: FC = ({ key: "tab", defaultValue: "applications", }); - // Unknown values, and the settings tab for users who cannot view it, fall - // back to the applications tab rather than selecting nothing. + // A value matching no trigger would leave no tab selected. const activeTab = tabState.value === "settings" && settings ? "settings" : "applications"; @@ -83,8 +82,7 @@ const OAuth2AppsSettingsPageView: FC = ({
{/* * The header sits outside the tabs, so a tab-specific action here would - * promise to act on content it navigates away from. Adding an - * application belongs to the applications tab alone. + * promise to act on content it navigates away from. */} = ({ - {isLoading ? ( + {isLoadingApps ? ( ) : !error && (!apps || apps.length === 0) ? ( Date: Sat, 1 Aug 2026 10:57:24 -0700 Subject: [PATCH 22/32] docs: document removing an OAuth2 application The Dynamic Client Registration section tells an admin that clients which already registered keep working until they are removed from the Applications tab, and nothing in this file said how to remove one. `Revoke Access` covers only token revocation, which ends sessions but leaves the registration in place, so its heading read as if it covered both. Add the deletion path, in the UI and through the API, and say plainly that the two are different operations. Reword the disable sentence, which sat under the UI steps and next to "Enabling asks for confirmation" and so read as a claim that disabling cuts off registered clients. The closing paragraph of the same section says the opposite, and is the correct one. Bring the touched blocks in line with the style guide: a greater-than sign for navigation rather than an arrow, terminal periods on complete sentences in ordered lists, one sentence per source line, and "select" rather than "open". The navigation separator two lines above was already wrong and is fixed too, since leaving it makes the file inconsistent where it was not before. --- docs/admin/integrations/oauth2-provider.md | 38 ++++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 1a37cf14566..51c588e513c 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -36,8 +36,8 @@ CODER_EXPERIMENTS=oauth2 ### Method 1: Web UI -1. Navigate to **Deployment Settings** → **OAuth2 Applications** -2. Click **Create Application** +1. Navigate to **Deployment Settings** > **OAuth2 Applications**. +2. On the **Applications** tab, select **Add application**. 3. Fill in the application details: - **Name**: Your application name - **Callback URL**: `https://yourapp.example.com/callback` (web) or `myapp://callback` (native/desktop) @@ -73,11 +73,16 @@ Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rf Change the setting in the web UI: -1. Navigate to **Deployment Settings** → **OAuth2 Applications** -2. Open the **Settings** tab -3. Select **Enable** or **Disable** next to **Dynamic Client Registration** +1. Navigate to **Deployment Settings** > **OAuth2 Applications**. +2. Select the **Settings** tab. +3. Select **Enable** or **Disable** next to **Dynamic Client Registration**. -Enabling asks for confirmation. Disabling takes effect immediately. +Enabling asks you to confirm first. +Disabling does not. +The tab is linkable directly at `https://$CODER_ACCESS_URL/deployment/oauth2-provider/apps?tab=settings`. + +Viewing the tab requires permission to view deployment configuration, and changing the setting requires permission to edit it. +Without edit permission the button is present but inactive, and the page says why. Check or change the setting with the CLI: @@ -256,6 +261,27 @@ curl -X DELETE \ "$CODER_URL/oauth2/tokens?client_id=$CLIENT_ID" ``` +This ends existing sessions but leaves the application registered, so it can authorize again. + +### Delete an Application + +Deleting an application is a separate operation from revoking its tokens. +It removes the registration itself, so the client cannot authorize again without being registered anew. + +In the web UI, navigate to **Deployment Settings** > **OAuth2 Applications**, select the application on the **Applications** tab, then select **Delete**. +This requires permission to delete OAuth2 applications. + +Or with the management API: + +```sh +curl -X DELETE \ + -H "Authorization: Bearer $CODER_SESSION_TOKEN" \ + "$CODER_URL/api/v2/oauth2-provider/apps/$APP_ID" +``` + +This is also how you remove clients that registered themselves while dynamic client registration was enabled. +Turning the setting off stops new registrations; it does not remove the ones already there. + ## Testing and Development Coder provides comprehensive test scripts for OAuth2 development: From 52f3630653e9b205f7b52dca5c487ea641b5e067 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sat, 1 Aug 2026 11:54:09 -0700 Subject: [PATCH 23/32] fix(site): seed the settings cache from the save response `invalidateQueries` resolves whether or not the refetch that follows succeeds, and a failed refetch keeps the query's last successful data while setting an error. A save that returned 200 therefore rendered the pre-save value under a red alert: no `Enabled` badge, a button still reading Enable, and no success signal to contradict any of it. Of the two directions this control drives, the one that misreported was the one that opens an unauthenticated registration endpoint. Write the response into the cache before invalidating, which is what `putApp` three functions up already does. The invalidation stays, so the freshness it buys is unchanged; what goes away is the window where a second network failure inverts the displayed state. The existing test could not see this: it called `onSuccess()` with no argument, against a client with no observers, so nothing refetched. It now passes the response through and asserts the cache holds it. --- site/src/api/queries/oauth2.test.ts | 22 +++++++++++++++++++++- site/src/api/queries/oauth2.ts | 7 ++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/site/src/api/queries/oauth2.test.ts b/site/src/api/queries/oauth2.test.ts index 3d8a8352212..5d53015137e 100644 --- a/site/src/api/queries/oauth2.test.ts +++ b/site/src/api/queries/oauth2.test.ts @@ -56,11 +56,31 @@ describe("putSettings", () => { }); queryClient.setQueryData(appQueryKey, { id: "app-1" }); - await putSettings(queryClient).onSuccess(); + await putSettings(queryClient).onSuccess(settings); expect(queryClient.getQueryState(settingsQueryKey)?.isInvalidated).toBe( true, ); expect(queryClient.getQueryState(appQueryKey)?.isInvalidated).toBe(false); }); + + // Invalidating resolves whether or not the refetch that follows succeeds, and + // a failed refetch keeps the query's last successful data. Seeding the cache + // from the response the server just returned is what stops a successful save + // from rendering the pre-save value under an error alert. + it("writes the saved value into the cache", async () => { + const queryClient = createTestQueryClient(); + const settingsQueryKey = getSettings().queryKey; + queryClient.setQueryData(settingsQueryKey, { + dynamic_client_registration_enabled: false, + }); + + await putSettings(queryClient).onSuccess({ + dynamic_client_registration_enabled: true, + }); + + expect(queryClient.getQueryData(settingsQueryKey)).toEqual({ + dynamic_client_registration_enabled: true, + }); + }); }); diff --git a/site/src/api/queries/oauth2.ts b/site/src/api/queries/oauth2.ts index 8085f970f36..76f29c48c64 100644 --- a/site/src/api/queries/oauth2.ts +++ b/site/src/api/queries/oauth2.ts @@ -134,7 +134,12 @@ export const getSettings = () => { export const putSettings = (queryClient: QueryClient) => { return { mutationFn: API.putOAuth2ProviderSettings, - onSuccess: async () => { + // Seed from the response before invalidating. Invalidating resolves + // whether or not the refetch succeeds, and a failed refetch keeps the + // last successful data, which would render the pre-save value under an + // error alert for a save that worked. + onSuccess: async (settings: TypesGen.OAuth2ProviderSettings) => { + queryClient.setQueryData(oauth2ProviderSettingsKey, settings); await queryClient.invalidateQueries({ queryKey: oauth2ProviderSettingsKey, }); From d1e4a82008ede6add269fc6faa11faae7a63698b Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sat, 1 Aug 2026 12:09:47 -0700 Subject: [PATCH 24/32] fix(site): separate the settings load and update errors The two travelled in one field joined by `??`, which assumes they cannot both be set. They can: a refetch that fails after a successful one keeps the data and sets the error, and that error then stays until a fetch succeeds. From that point every failed save was discarded in favour of the older failure, so an admin whose PUT returned 403 because their role changed kept reading an internal server error from minutes earlier, and the actionable one never reached them. They also need opposite treatment. A load failure means there is no value to act on, so the control must not render. An update failure leaves the value valid, so the control stays and the admin can retry. Merged, the view could not ask which one it had, so it asked whether the value was undefined and used that as a stand-in. That worked only because a load failure happens to leave the value undefined. Split them, and decide the four states in order in one place: loading, then no value with or without a load error, then the value with the update error winning the alert because it reports the action just taken. Whether the control renders now follows from whether there is a value, not from which error is set. The `boolean | undefined` type stays. An offline query is `fetchStatus: "paused"`, so `isLoading` is false with no data and no error, and that branch is the only thing standing between an offline admin and a blank tab. --- .../OAuth2AppsSettingsPage.tsx | 3 +- .../OAuth2AppsSettingsPageView.stories.tsx | 37 ++++++++- .../OAuth2AppsSettingsPageView.tsx | 75 ++++++++++++------- 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx index 2751fbdaaf5..a06e72b016e 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx @@ -37,7 +37,8 @@ const OAuth2AppsSettingsPage: FC = () => { canEdit: canEditSettings, isLoading: settingsQuery.isLoading, isUpdating: updateSettingsMutation.isPending, - error: settingsQuery.error ?? updateSettingsMutation.error, + loadError: settingsQuery.error, + updateError: updateSettingsMutation.error, dynamicClientRegistrationEnabled: settingsQuery.data?.dynamic_client_registration_enabled, onDynamicClientRegistrationChange: (enabled) => { diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index 66e7a028073..dd2b20301d5 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -10,7 +10,8 @@ const MockSettingsTab = { canEdit: true, isLoading: false, isUpdating: false, - error: undefined, + loadError: undefined, + updateError: undefined, dynamicClientRegistrationEnabled: false, onDynamicClientRegistrationChange: fn(), }; @@ -130,7 +131,7 @@ export const SettingsFetchErrorKeepsAppsEmptyState: Story = { apps: [], settings: { ...MockSettingsTab, - error: "settings boom", + loadError: "settings boom", dynamicClientRegistrationEnabled: undefined, }, }, @@ -157,7 +158,7 @@ export const SettingsUpdateErrorKeepsSettingVisible: Story = { args: { isLoadingApps: false, apps: MockOAuth2ProviderApps, - settings: { ...MockSettingsTab, error: "update boom" }, + settings: { ...MockSettingsTab, updateError: "update boom" }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -168,6 +169,36 @@ export const SettingsUpdateErrorKeepsSettingVisible: Story = { }, }; +/** + * A load failure that left a usable value behind must not hide the failure of + * the save the admin just attempted. This is the state a failed post-save + * refetch produces, and the older error used to win it. + */ +export const UpdateErrorOutranksStaleLoadError: Story = { + args: { + isLoadingApps: false, + apps: MockOAuth2ProviderApps, + settings: { + ...MockSettingsTab, + loadError: "stale refetch failure", + updateError: "forbidden: your role changed", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect( + canvas.getByText("forbidden: your role changed"), + ).toBeVisible(); + await expect( + canvas.queryByText("stale refetch failure"), + ).not.toBeInTheDocument(); + // The value is still valid, so the control stays and the admin can retry. + await expect(canvas.getByRole("button", { name: "Enable" })).toBeVisible(); + }, +}; + /** * The value is optional on the wire. A response that omits it must not leave * the tab silently blank. diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index 3553ffee0bc..9b591111e10 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -41,7 +41,16 @@ type SettingsTab = { canEdit: boolean; isLoading: boolean; isUpdating: boolean; - error: unknown; + /** + * Kept apart because the two need opposite treatment. A load failure means + * there is no value to act on, so the control must not render. An update + * failure leaves the value valid, so the control stays and the admin can + * retry. Merging them also let the older one hide the newer. + */ + loadError: unknown; + updateError: unknown; + // Stays optional: an offline query is `fetchStatus: "paused"`, so `isLoading` + // is false with no data and no error. dynamicClientRegistrationEnabled: boolean | undefined; onDynamicClientRegistrationChange: (enabled: boolean) => void; }; @@ -54,6 +63,41 @@ type OAuth2AppsSettingsProps = { settings?: SettingsTab; }; +/** + * Four states, decided in order. Whether the control renders depends on whether + * there is a value to act on, never on which error happens to be set, and the + * update error wins the alert because it reports the action the admin just took. + */ +const SettingsTabBody: FC<{ settings: SettingsTab }> = ({ settings }) => { + if (settings.isLoading) { + return ; + } + + if (settings.dynamicClientRegistrationEnabled === undefined) { + if (settings.loadError) { + return ; + } + return ( +

+ Settings are unavailable. +

+ ); + } + + const alertError = settings.updateError ?? settings.loadError; + return ( +
+ {Boolean(alertError) && } + +
+ ); +}; + const AddApplicationButton: FC = () => ( + + { + setIsUpdating(true); + setPending(next); + }} + /> +
); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const button = canvas.getByRole("button", { name: "Disable" }); + const finish = canvas.getByRole("button", { name: "Finish request" }); button.focus(); await userEvent.keyboard("{Enter}"); - // Mid-request. A `disabled` attribute here would have blurred to . + // Mid-request, and it stays mid-request until the click below. A `disabled` + // attribute here would have blurred to . await expect(button).toHaveAttribute("aria-disabled", "true"); await expect(button).toHaveFocus(); + // `fireEvent`, not `userEvent`: clicking with a pointer would move focus to + // the harness button and destroy the state under test. + fireEvent.click(finish); + // The same element becomes the opposite action once the request lands, and // focus rides along rather than resetting to the top of the document. - await waitFor(() => - expect(canvas.getByRole("button", { name: "Enable" })).toBeVisible(), - ); + await expect(canvas.getByText("Enable")).toBeVisible(); await expect(button).toHaveFocus(); }, }; @@ -221,9 +248,10 @@ export const KeepsFocusWhileUpdating: Story = { * open, the dialog stays put and the admin closes it themselves. It must * never open, close, or reopen on its own as `enabled` changes underneath. * - * The external change is armed on a timer rather than driven by a control - * clicked mid-dialog. The dialog is modal, so any pointer interaction outside - * it dismisses it, which would destroy the state under test. + * The external change is driven by the story rather than a timer, and applied + * with `fireEvent` so no `pointerdown` reaches Radix's dismiss layer. A real + * pointer click outside a modal dialog closes it, which would destroy the state + * under test. */ export const SurvivesExternalEnabledChanges: Story = { render: function Harness(args) { @@ -231,20 +259,14 @@ export const SurvivesExternalEnabledChanges: Story = { return (
- - - +
+ + +
- new Promise((resolve) => setTimeout(resolve, 400)); + // Role queries skip the story root once the modal marks it aria-hidden, so + // these are found by text and captured before the dialog opens. + const enableExternally = canvas.getByText("Enable externally"); + const disableExternally = canvas.getByText("Disable externally"); - await userEvent.click( - canvas.getByRole("button", { name: "Arm external enable" }), - ); await userEvent.click(canvas.getByRole("button", { name: "Enable" })); await waitFor(() => expect(body.getByText(title)).toBeVisible()); const dialog = body.getByTestId("dialog"); - // The armed change lands here. The dialog ignores it: the admin's intent - // to confirm is theirs to resolve, not the server's. - await settleTransition(); - await expect(body.getByText(title)).toBeVisible(); - // Still the same node, so it was never torn down and rebuilt. + // The external change lands here, with the dialog open. The dialog ignores + // it: the admin's intent to confirm is theirs to resolve, not the server's. + fireEvent.click(enableExternally); + + // Radix flips `data-state` to "closed" the moment something closes the + // dialog, so this needs no waiting and cannot be fooled by an animation + // still in progress. + await expect(dialog).toHaveAttribute("data-state", "open"); await expect(body.getByTestId("dialog")).toBe(dialog); // Cancelling is the admin's own action, so it closes. @@ -290,10 +310,7 @@ export const SurvivesExternalEnabledChanges: Story = { ); // Going back to disabled is the transition that used to resurrect it. - await userEvent.click( - canvas.getByRole("button", { name: "Set externally disabled" }), - ); - await settleTransition(); + fireEvent.click(disableExternally); await expect(body.queryByText(title)).not.toBeInTheDocument(); await expect(args.onChange).not.toHaveBeenCalled(); }, From 553349149c1f88429aa86be03fee3a18c744f020 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sat, 1 Aug 2026 15:52:07 -0700 Subject: [PATCH 26/32] fix(site): return focus to the setting button when the dialog closes `ConfirmDialog` renders no `DialogTrigger`, so Radix has nothing to restore focus to on close and it lands on ``. A keyboard admin who enabled DCR was returned to the top of the document, and their next Tab started at the page chrome rather than the button whose label had just changed. That is the same loss the `aria-disabled` handling exists to prevent, on the one path that opens a dialog, and the existing focus story never walked it because the disable path has no dialog. Focusing from `onConfirm` and `onClose` does not hold. Radix moves focus again when the exit animation ends, so a synchronous call is overwritten a frame later. `onCloseAutoFocus` is the point Radix provides for this, and `ConfirmDialog` did not forward it. Add it as an optional passthrough. Nothing changes for the other call sites unless they pass it, and preventing the default there covers every way the dialog closes rather than needing a call in each handler. Restoring focus for every `ConfirmDialog` rather than per caller is the better repair, and it is tracked separately since it changes behaviour for 45 call sites. --- .../Dialog/ConfirmDialog/ConfirmDialog.tsx | 9 +++++++ ...namicClientRegistrationSetting.stories.tsx | 25 +++++++++++++++++++ .../DynamicClientRegistrationSetting.tsx | 12 ++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/site/src/components/Dialog/ConfirmDialog/ConfirmDialog.tsx b/site/src/components/Dialog/ConfirmDialog/ConfirmDialog.tsx index afc69d2c9ce..2af1c478354 100644 --- a/site/src/components/Dialog/ConfirmDialog/ConfirmDialog.tsx +++ b/site/src/components/Dialog/ConfirmDialog/ConfirmDialog.tsx @@ -53,6 +53,13 @@ export interface ConfirmDialogProps { * Defaults to shown for "delete", hidden for "info"/"success". */ readonly hideCancel?: boolean; + /** + * Forwarded to Radix. This dialog renders no `DialogTrigger`, so Radix has + * nothing to return focus to on close and it lands on ``. Callers that + * open it from a control the user should return to can preventDefault here + * and focus that control instead. + */ + readonly onCloseAutoFocus?: (event: Event) => void; } /** @@ -66,6 +73,7 @@ export const ConfirmDialog: FC = ({ disabled = false, hideCancel, onClose, + onCloseAutoFocus, onConfirm, open = false, title, @@ -88,6 +96,7 @@ export const ConfirmDialog: FC = ({ {title} diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx index f9dad186b7b..015b4e2dfc6 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -242,6 +242,31 @@ export const KeepsFocusWhileUpdating: Story = { }, }; +/** + * The enable path opens a dialog, and closing one returns focus to whatever + * opened it. `ConfirmDialog` renders no Radix trigger, so Radix has nothing to + * restore to and focus would otherwise land on ``, which is the same loss + * the in-flight handling above exists to prevent. + */ +export const KeepsFocusAfterConfirming: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + const button = canvas.getByRole("button", { name: "Enable" }); + + button.focus(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => expect(body.getByTestId("dialog")).toBeVisible()); + + await userEvent.click(body.getByTestId("confirm-button")); + await waitFor(() => + expect(body.queryByTestId("dialog")).not.toBeInTheDocument(), + ); + + await expect(button).toHaveFocus(); + }, +}; + /** * The dialog's visibility follows only the admin's own intent, never the * server value. When the setting is enabled elsewhere while the dialog is diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx index 72d14acfdd6..3ff77ebb752 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -1,4 +1,4 @@ -import { type FC, useId, useState } from "react"; +import { type FC, useId, useRef, useState } from "react"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; @@ -16,6 +16,7 @@ export const DynamicClientRegistrationSetting: FC< > = ({ enabled, canEdit, isUpdating, onChange }) => { const headingId = useId(); const [isEnableDialogOpen, setIsEnableDialogOpen] = useState(false); + const buttonRef = useRef(null); return ( <> @@ -69,6 +70,7 @@ export const DynamicClientRegistrationSetting: FC< * which drops a keyboard user back to the top of the document mid-flip. */}