diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 4411b17c58b30..51c588e513c23 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) @@ -71,6 +71,19 @@ 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. Select the **Settings** tab. +3. Select **Enable** or **Disable** next to **Dynamic Client Registration**. + +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: ```sh @@ -248,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: diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 446467c52bbaa..f832533a53bf3 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/api.ts b/site/src/api/api.ts index b87d08a9f2ba8..c7de79f98a7f9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2035,6 +2035,24 @@ 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.test.ts b/site/src/api/queries/oauth2.test.ts new file mode 100644 index 0000000000000..5c657c2050102 --- /dev/null +++ b/site/src/api/queries/oauth2.test.ts @@ -0,0 +1,82 @@ +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 { getSettings, oauth2ProviderAppKey, putSettings } from "./oauth2"; + +vi.mock("#/api/api", () => ({ + API: { + getOAuth2ProviderSettings: vi.fn(), + putOAuth2ProviderSettings: vi.fn(), + }, +})); + +const settings: TypesGen.OAuth2ProviderSettings = { + dynamic_client_registration_enabled: true, +}; + +describe("getSettings", () => { + it("fetches settings via the API client", async () => { + const getSettingsMock = vi.mocked(API.getOAuth2ProviderSettings); + getSettingsMock.mockResolvedValue(settings); + + const result = await getSettings().queryFn(); + + expect(getSettingsMock).toHaveBeenCalled(); + expect(result).toEqual(settings); + }); +}); + +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 putSettings(queryClient).mutationFn(settings); + + expect(putSettingsMock).toHaveBeenCalledWith(settings); + expect(result).toEqual(settings); + }); + + // `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(); + const settingsQueryKey = getSettings().queryKey; + const appQueryKey = oauth2ProviderAppKey("app-1"); + queryClient.setQueryData(settingsQueryKey, { + dynamic_client_registration_enabled: false, + }); + queryClient.setQueryData(appQueryKey, { id: "app-1" }); + + 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 4881e8ad6c800..e7c550807d74a 100644 --- a/site/src/api/queries/oauth2.ts +++ b/site/src/api/queries/oauth2.ts @@ -2,13 +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"]; +export 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); +export const oauth2ProviderSettingsKey = oauth2ProviderKey.concat("settings"); export const getGitHubDevice = () => { return { @@ -121,3 +123,26 @@ export const revokeApp = (queryClient: QueryClient, userId: string) => { }, }; }; + +export const getSettings = () => { + return { + queryKey: oauth2ProviderSettingsKey, + queryFn: () => API.getOAuth2ProviderSettings(), + }; +}; + +export const putSettings = (queryClient: QueryClient) => { + return { + mutationFn: API.putOAuth2ProviderSettings, + // 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, + }); + }, + }; +}; diff --git a/site/src/components/Dialog/ConfirmDialog/ConfirmDialog.tsx b/site/src/components/Dialog/ConfirmDialog/ConfirmDialog.tsx index afc69d2c9ce24..2af1c478354e3 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 new file mode 100644 index 0000000000000..8a42803df4f93 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.stories.tsx @@ -0,0 +1,371 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { + expect, + fireEvent, + fn, + userEvent, + waitFor, + within, +} from "storybook/test"; +import { DynamicClientRegistrationSetting } from "./DynamicClientRegistrationSetting"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting", + component: DynamicClientRegistrationSetting, + args: { + enabled: false, + canEdit: true, + isUpdating: false, + 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(); + // The endpoint is what an admin has to hand a client after enabling. + await expect(canvas.getByText("/oauth2/register")).toBeVisible(); + // 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 an administrator deletes them/), + ).toBeVisible(); + }, +}; + +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(); + await expect( + canvas.getByText(/keep working until an administrator deletes them/), + ).toBeVisible(); + }, +}; + +export const ReadOnly: Story = { + args: { + canEdit: false, + }, + play: async ({ canvasElement }) => { + 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(); + }, +}; + +export const EnabledReadOnly: Story = { + args: { + enabled: true, + canEdit: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("button", { name: "Disable" }), + ).toBeDisabled(); + await expect( + canvas.getByText(/permission to edit deployment configuration/), + ).toBeVisible(); + }, +}; + +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(); + + // 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( + body.getByText(/no Coder account and no administrator approval/), + ).toBeInTheDocument(); + await expect( + body.getByText(/does not revoke clients that already registered/), + ).toBeInTheDocument(); + + await userEvent.click(body.getByTestId("confirm-button")); + await expect(args.onChange).toHaveBeenCalledWith(true); + // Closing is asserted twice on the cancel path and was asserted nowhere on + // this one, which is the direction that opens the endpoint. A modal left + // standing would cover the badge that reports the save worked. + await waitFor(() => + expect( + body.queryByText("Enable Dynamic Client Registration?"), + ).not.toBeInTheDocument(), + ); + }, +}; + +export const CancelEnable: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + 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 closes the dialog, which is the only thing this story can + // prove. `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(), + ); + }, +}; + +export const Updating: Story = { + args: { + isUpdating: true, + }, + 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(); + // `aria-disabled` does not stop a keyboard Enter; the guard in `onClick` + // does. In this direction Enter would open the dialog, not call `onChange`, + // so the call assertion above cannot see the guard go missing. + await expect( + within(canvasElement.ownerDocument.body).queryByText( + "Enable Dynamic Client Registration?", + ), + ).not.toBeInTheDocument(); + + // 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(); + }, +}; + +export const UpdatingWhileEnabled: Story = { + args: { + 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); + const [pending, setPending] = useState(undefined); + + return ( +
+ {/* + * The request ends when the story says so, not when a timer says so. + * A timer would race the assertions that require the in-flight state, + * and this suite is documented to stall under CPU contention. + */} + + + { + 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, 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 expect(canvas.getByText("Enable")).toBeVisible(); + await expect(button).toHaveFocus(); + }, +}; + +/** + * 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 + * 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 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) { + 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?"; + + // 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: "Enable" })); + await waitFor(() => expect(body.getByText(title)).toBeVisible()); + const dialog = body.getByTestId("dialog"); + + // 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. + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(body.queryByText(title)).not.toBeInTheDocument(), + ); + + // Returning to disabled must not reopen the dialog. + fireEvent.click(disableExternally); + await expect(body.queryByText(title)).not.toBeInTheDocument(); + await expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + +export const DisableSkipsConfirmationDialog: 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/DynamicClientRegistrationSetting.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx new file mode 100644 index 0000000000000..1d306b03f2c44 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting.tsx @@ -0,0 +1,117 @@ +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"; +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, isUpdating, onChange }) => { + const headingId = useId(); + const [isEnableDialogOpen, setIsEnableDialogOpen] = useState(false); + const buttonRef = useRef(null); + + return ( + <> +
+
+
+

+ Dynamic Client Registration +

+ {enabled && ( + + Enabled + + )} +
+ {/* + * 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 at{" "} + /oauth2/register without prior + administrator approval (RFC 7591). Disabling stops new + registrations. Clients that already registered keep working until an + administrator deletes them. +

+ {/* + * 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. +

+ )} +
+ + {/* + * 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. + */} + +
+ + { + setIsEnableDialogOpen(false); + onChange(true); + }} + onClose={() => setIsEnableDialogOpen(false)} + // Radix returns focus to its trigger on close, and this dialog has + // none, so focus would land on . Focusing from the handlers + // above does not survive: Radix moves focus again when the exit + // animation ends. + onCloseAutoFocus={(event) => { + event.preventDefault(); + buttonRef.current?.focus(); + }} + title="Enable Dynamic Client Registration?" + confirmText="Enable" + 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." + /> + + ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.stories.tsx new file mode 100644 index 0000000000000..4a2da8d58c60d --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { + oauth2ProviderAppsKey, + oauth2ProviderSettingsKey, +} from "#/api/queries/oauth2"; +import { + MockOAuth2ProviderApps, + MockOAuth2ProviderSettings, + MockUserOwner, +} from "#/testHelpers/entities"; +import { withAuthProvider } from "#/testHelpers/storybook"; +import OAuth2AppsSettingsPage from "./OAuth2AppsSettingsPage"; + +/** + * Presentation is covered by the view and the setting component. These stories + * exist for the one thing only the container does: turn two RBAC permissions + * into the props that decide whether an admin is offered a deployment-wide + * security switch. Reading the wrong permission produces a page that looks + * correct and fails at the API. + */ +const meta: Meta = { + title: "pages/DeploymentSettingsPage/OAuth2AppsSettingsPage", + component: OAuth2AppsSettingsPage, + decorators: [withAuthProvider], + parameters: { + user: MockUserOwner, + queries: [ + { key: oauth2ProviderAppsKey, data: MockOAuth2ProviderApps }, + { key: oauth2ProviderSettingsKey, data: MockOAuth2ProviderSettings }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const CanEditSettings: Story = { + parameters: { + permissions: { + createOAuth2App: true, + viewDeploymentConfig: true, + editDeploymentConfig: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect(canvas.getByRole("button", { name: "Enable" })).toBeEnabled(); + }, +}; + +/** + * `editDeploymentConfig` is the update permission the endpoint enforces, and + * the Auditor role holds read without it. Deriving the button from the read + * permission instead hands that role a live control that 403s. + */ +export const ViewOnlyCannotEditSettings: Story = { + parameters: { + permissions: { + createOAuth2App: true, + viewDeploymentConfig: true, + editDeploymentConfig: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect(canvas.getByRole("button", { name: "Enable" })).toBeDisabled(); + await expect( + canvas.getByText(/permission to edit deployment configuration/), + ).toBeVisible(); + }, +}; + +/** + * Edit is granted here and view is not, which is not a combination RBAC + * produces. It is the point: the tab has to follow the read permission, so + * granting the other one must not reveal it. + */ +export const WithoutViewPermissionHidesSettings: Story = { + parameters: { + permissions: { + createOAuth2App: true, + viewDeploymentConfig: false, + editDeploymentConfig: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("tab", { name: "Applications" }), + ).toBeVisible(); + await expect( + canvas.queryByRole("tab", { name: "Settings" }), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByRole("button", { name: "Enable" }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx index a3a14e332f80e..a709d578b51ae 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, getSettings, putSettings } 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 appsQuery = useQuery(getApps()); + const queryClient = useQueryClient(); const canCreateApp = permissions.createOAuth2App; + // Gates the query and the prop below. Spelled once because a disabled query + // reports `isLoading: false` with no data, so the two drifting apart would + // render the tab permanently on its absent-value branch, with no spinner and + // no error to explain it. + const canViewSettings = permissions.viewDeploymentConfig; + const canEditSettings = permissions.editDeploymentConfig; + + const appsQuery = useQuery(getApps()); + const settingsQuery = useQuery({ + ...getSettings(), + enabled: canViewSettings, + }); + const updateSettingsMutation = useMutation(putSettings(queryClient)); return ( <> @@ -17,9 +30,28 @@ const OAuth2AppsSettingsPage: FC = () => { void settingsQuery.refetch(), + updateError: updateSettingsMutation.error, + dynamicClientRegistrationEnabled: + settingsQuery.data?.dynamic_client_registration_enabled, + onDynamicClientRegistrationChange: (enabled) => { + updateSettingsMutation.mutate({ + dynamic_client_registration_enabled: enabled, + }); + }, + } + : undefined + } /> ); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index ef3cb419ce91a..031b3fcc070a0 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -1,12 +1,28 @@ 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"; -const meta: Meta = { +// 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, + loadError: undefined, + updateError: undefined, + onRetry: fn(), + dynamicClientRegistrationEnabled: false, + onDynamicClientRegistrationChange: fn(), +}; + +const meta: Meta = { title: "pages/DeploymentSettingsPage/OAuth2AppsSettingsPageView", component: OAuth2AppsSettingsPageView, args: { canCreateApp: true, + settings: MockSettingsTab, }, }; export default meta; @@ -15,27 +31,42 @@ type Story = StoryObj; export const Loading: Story = { args: { - isLoading: true, + isLoadingApps: true, }, }; +/** + * An apps failure belongs to the applications tab. Rendered above the tabs it + * would sit over a settings panel that loaded fine, at the moment the admin is + * deciding whether to open self-registration. + */ export const WithError: Story = { args: { - isLoading: false, - error: "some error", + isLoadingApps: false, + appsError: "some error", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("some error")).toBeVisible(); + + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + await expect(canvas.queryByText("some error")).not.toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("tab", { name: "Applications" })); + await expect(canvas.getByText("some error")).toBeVisible(); }, }; export const Apps: Story = { args: { - isLoading: false, + isLoadingApps: false, apps: MockOAuth2ProviderApps, }, }; export const Empty: Story = { args: { - isLoading: false, + isLoadingApps: false, }, }; @@ -44,3 +75,290 @@ export const NoCreatePermissions: Story = { canCreateApp: false, }, }; + +// Setting behavior is covered in DynamicClientRegistrationSetting.stories.tsx; +// this covers only the wiring. +export const SettingsTabWiresDynamicClientRegistration: Story = { + args: { + 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.settings?.onDynamicClientRegistrationChange, + ).toHaveBeenCalledWith(false); + }, +}; + +/** + * An in-flight save reaches the control only through this view. Container + * stories cover the permission props by rendering the whole page, but they + * cannot hold a mutation pending, so the passthrough is pinned here. + */ +export const SettingsTabUpdating: Story = { + args: { + settings: { ...MockSettingsTab, isUpdating: true }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + await expect( + canvas.getByRole("button", { name: "Enable" }), + ).toHaveAttribute("aria-disabled", "true"); + }, +}; + +/** + * The description is header content, outside the tabs, so it has to describe + * what the page actually offers this viewer. Its second clause belongs to the + * settings tab and goes with it. + */ +export const DescriptionCoversSettingsWhenPermitted: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText( + "Register applications to use Coder as an OAuth2 provider, and configure how this deployment behaves as one.", + ), + ).toBeVisible(); + }, +}; + +export const SettingsTabHiddenWithoutPermission: Story = { + args: { + settings: 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(); + await expect( + canvas.getByText( + "Register applications to use Coder as an OAuth2 provider.", + ), + ).toBeVisible(); + }, +}; + +/** + * 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: { + isLoadingApps: false, + apps: MockOAuth2ProviderApps, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = canvas.getByRole("link", { name: "Add application" }); + await expect(header).toBeVisible(); + + // The docs link is tab-agnostic and stays put, which is what makes the + // other one's disappearance a scoping decision rather than a quirk. + const docsLink = canvas.getByRole("link", { name: /read the docs/i }); + await expect(docsLink).toBeVisible(); + + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + await expect( + canvas.queryByRole("link", { name: "Add application" }), + ).not.toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: /read the docs/i }), + ).toBeVisible(); + + 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` + * prop gates that message. + */ +export const SettingsFetchErrorKeepsAppsEmptyState: Story = { + args: { + isLoadingApps: false, + apps: [], + settings: { + ...MockSettingsTab, + loadError: "settings boom", + dynamicClientRegistrationEnabled: undefined, + }, + }, + play: async ({ args, 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(); + // One condition, one explanation. The fallback copy is for a value that is + // absent without an error, not for an error. + await expect( + canvas.queryByText(/did not return a value/), + ).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Retry" })); + await expect(args.settings?.onRetry).toHaveBeenCalled(); + }, +}; + +/** + * 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: { + isLoadingApps: false, + apps: MockOAuth2ProviderApps, + settings: { ...MockSettingsTab, updateError: "update boom" }, + }, + 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(); + }, +}; + +/** + * 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. + */ +export const SettingsValueOmitted: Story = { + args: { + isLoadingApps: false, + settings: { + ...MockSettingsTab, + dynamicClientRegistrationEnabled: undefined, + }, + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("tab", { name: "Settings" })); + + // Names the cause and offers a way out. Nothing else recovers this state: + // retries are off, refetch-on-focus is off, and the control that would + // trigger an invalidation is the thing that is missing. + await expect( + canvas.getByText( + /did not return a value for Dynamic Client Registration/, + ), + ).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Retry" })); + await expect(args.settings?.onRetry).toHaveBeenCalled(); + }, +}; + +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(); + }, +}; + +export const UnpermittedTabFromUrlFallsBack: Story = { + args: { + settings: undefined, + }, + 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"); + // 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(); + }, +}; + +// So the tab does not pop into the tab bar when the request resolves. +export const SettingsTabLoading: Story = { + args: { + settings: { + ...MockSettingsTab, + isLoading: 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 be883b8b8b2a9..13556be813045 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -6,9 +6,11 @@ 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, + SettingsHeaderDocsLink, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; import { @@ -21,13 +23,96 @@ 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"; +import { useSearchParamsKey } from "#/hooks/useSearchParamsKey"; +import { docs } from "#/utils/docs"; +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; + /** + * 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; + /** + * Both terminal states below are dead ends without it: `retry: false` and + * `refetchOnWindowFocus: false` are set globally, the query outlives a tab + * switch, and the control that would trigger an invalidation is exactly what + * is not rendered. + */ + onRetry: () => void; + // 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; +}; type OAuth2AppsSettingsProps = { apps?: TypesGen.OAuth2ProviderApp[]; - isLoading: boolean; - error: unknown; + isLoadingApps: boolean; + appsError: unknown; canCreateApp: boolean; + 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) { + return ( +
+ {settings.loadError ? ( + + ) : ( +

+ Coder did not return a value for Dynamic Client Registration. This + can happen while the browser is offline. Retry, or check the setting + with coder oauth2-provider dcr. +

+ )} + +
+ ); + } + + const alertError = settings.updateError ?? settings.loadError; + return ( +
+ {Boolean(alertError) && } + +
+ ); }; const AddApplicationButton: FC = () => ( @@ -41,51 +126,101 @@ const AddApplicationButton: FC = () => ( const OAuth2AppsSettingsPageView: FC = ({ apps, - isLoading, - error, + isLoadingApps, + appsError, canCreateApp, + settings, }) => { + const tabState = useSearchParamsKey({ + key: "tab", + defaultValue: "applications", + }); + // A value matching no trigger would leave no tab selected. + const activeTab = + 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. The docs link is + * tab-agnostic, so it stays on both. + */} : undefined} + actions={ + <> + {canCreateApp && activeTab === "applications" && ( + + )} + + + } > OAuth2 applications + {/* + * The second clause describes the settings tab, which is absent for a + * viewer who cannot read deployment config. Promising it to someone + * with no control for it on the page sends them looking for one. + */} - Configure applications to use Coder as an OAuth2 provider. + Register applications to use Coder as an OAuth2 provider + {settings && ", and configure how this deployment behaves as one"}. - {Boolean(error) && ( -
- -
- )} + + + Applications + {settings && Settings} + - - - - Name - Callback URL - - Open - - - - - {isLoading ? ( - - ) : !error && (!apps || apps.length === 0) ? ( - : undefined} - /> - ) : ( - apps?.map((app) => ) + + {/* + * Inside the tab, for the same reason the settings error is: an + * error belongs with the content it describes. Outside, an apps + * failure sat above the settings panel and read as that panel's. + */} + {Boolean(appsError) && ( +
+ +
)} -
-
+ + + + Name + Callback URL + + Open + + + + + {isLoadingApps ? ( + + ) : !appsError && (!apps || apps.length === 0) ? ( + : undefined} + /> + ) : ( + apps?.map((app) => ) + )} + +
+ + + {settings && ( + + + + )} +
); }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bc3ef896c5d3b..e246e25231b4a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4867,6 +4867,10 @@ export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [ }, ]; +export const MockOAuth2ProviderSettings: TypesGen.OAuth2ProviderSettings = { + dynamic_client_registration_enabled: false, +}; + export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] = [ {