Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c0472a8
feat(site): add OAuth2 dynamic client registration toggle to deployme…
BobbyHo Jul 24, 2026
5eaecb1
test(site): cover the new oauth2 provider settings API and query helpers
BobbyHo Jul 25, 2026
af8db90
refactor(site): adopt designer mockup for the DCR setting
BobbyHo Jul 28, 2026
5c03561
refactor(site): move DynamicClientRegistrationSetting to its own file
BobbyHo Jul 29, 2026
659cfa4
test(site): co-locate DynamicClientRegistrationSetting stories
BobbyHo Jul 29, 2026
61711b0
refactor(site): use ConfirmDialog for the DCR confirmation
BobbyHo Jul 29, 2026
5a726d6
feat(site): add loading and pending states to the DCR setting
BobbyHo Jul 29, 2026
bd0231a
fix(site): close the DCR dialog when the setting is enabled elsewhere
BobbyHo Jul 29, 2026
b0cba2f
feat(site): link the OAuth2 settings tab to a query param
BobbyHo Jul 29, 2026
9ca7db2
Merge branch 'main' into coder-eng-3062-dcr-flag-ui
BobbyHo Jul 29, 2026
595bc63
fix(site): stop the DCR dialog reopening without user action
BobbyHo Jul 30, 2026
8bdf4a1
fix(site): name the consequences in the DCR confirmation dialog
BobbyHo Jul 30, 2026
45baa4c
fix(site): explain why the DCR buttons are disabled
BobbyHo Jul 30, 2026
c068c40
fix(site): say what disabling DCR does not undo
BobbyHo Jul 30, 2026
c2158c7
fix(site): keep the DCR button focusable while a request is in flight
BobbyHo Jul 31, 2026
a49c0cd
docs: add the web UI route to the DCR section
BobbyHo Jul 31, 2026
4177fcb
Merge branch 'main' into coder-eng-3062-dcr-flag-ui
BobbyHo Jul 31, 2026
0560e77
fix(site): scope the settings error to the settings tab
BobbyHo Jul 31, 2026
754b1a6
fix(site): follow the ConfirmDialog move to components/Dialog
BobbyHo Jul 31, 2026
90292cd
fix(site): group the settings props and scope the header action
BobbyHo Jul 31, 2026
0ea5890
test(site): assert the settings invalidation spares app queries
BobbyHo Jul 31, 2026
6bca8be
fix(site): address review nits on the DCR setting
BobbyHo Jul 31, 2026
3ededdc
refactor(site): follow module naming and trim restating comments
BobbyHo Jul 31, 2026
c0132af
docs: document removing an OAuth2 application
BobbyHo Aug 1, 2026
52f3630
fix(site): seed the settings cache from the save response
BobbyHo Aug 1, 2026
d1e4a82
fix(site): separate the settings load and update errors
BobbyHo Aug 1, 2026
a046ef3
test(site): end the simulated transitions on demand, not on a timer
BobbyHo Aug 1, 2026
5533491
fix(site): return focus to the setting button when the dialog closes
BobbyHo Aug 1, 2026
ad99fd3
fix(site): move the apps error into its tab and close four test gaps
BobbyHo Aug 2, 2026
b1bd105
feat(site): link the docs and name the registration endpoint
BobbyHo Aug 2, 2026
cecf2bc
feat(site): add a retry action to the OAuth2 settings tab
BobbyHo Aug 2, 2026
c98592c
test(site): cover the OAuth2 settings permission gates
BobbyHo Aug 2, 2026
036c528
fix(site): stop pointing the DCR caveat at the Applications tab
BobbyHo Aug 2, 2026
f728c50
refactor(site): tighten the OAuth2 settings page seams
BobbyHo Aug 2, 2026
ba1bdf0
Merge branch 'main' into coder-eng-3062-dcr-flag-ui
BobbyHo Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions docs/admin/integrations/oauth2-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@
CODER_EXPERIMENTS=oauth2
```

## Creating OAuth2 Applications

Check warning on line 35 in docs/admin/integrations/oauth2-provider.md

View workflow job for this annotation

GitHub Actions / lint-docs

Coder.GerundHeading

Heading starts with an -ing word ('Creating'); prefer the imperative ('Install') or the noun ('Installation'). See capitalization-and-punctuation.md#no-gerund-leading-headings.

### 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)
Expand Down Expand Up @@ -71,6 +71,19 @@

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
Expand Down Expand Up @@ -248,7 +261,28 @@
"$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

Check warning on line 285 in docs/admin/integrations/oauth2-provider.md

View workflow job for this annotation

GitHub Actions / lint-docs

Coder.GerundHeading

Heading starts with an -ing word ('Testing'); prefer the imperative ('Install') or the noun ('Installation'). See capitalization-and-punctuation.md#no-gerund-leading-headings.

Coder provides comprehensive test scripts for OAuth2 development:

Expand Down
49 changes: 49 additions & 0 deletions site/src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
});
18 changes: 18 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2035,6 +2035,24 @@ class ApiMethods {
await this.axios.delete(`/oauth2/tokens?client_id=${appId}`);
};

getOAuth2ProviderSettings =
Comment thread
BobbyHo marked this conversation as resolved.
async (): Promise<TypesGen.OAuth2ProviderSettings> => {
const resp = await this.axios.get<TypesGen.OAuth2ProviderSettings>(
"/api/v2/oauth2-provider/settings",
);
return resp.data;
};

putOAuth2ProviderSettings = async (
data: TypesGen.OAuth2ProviderSettings,
): Promise<TypesGen.OAuth2ProviderSettings> => {
const resp = await this.axios.put<TypesGen.OAuth2ProviderSettings>(
"/api/v2/oauth2-provider/settings",
data,
);
return resp.data;
};

getAuditLogs = async (
options: TypesGen.AuditLogsRequest,
): Promise<TypesGen.AuditLogResponse> => {
Expand Down
82 changes: 82 additions & 0 deletions site/src/api/queries/oauth2.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
27 changes: 26 additions & 1 deletion site/src/api/queries/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Comment thread
BobbyHo marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -121,3 +123,26 @@ export const revokeApp = (queryClient: QueryClient, userId: string) => {
},
};
};

export const getSettings = () => {
Comment thread
BobbyHo marked this conversation as resolved.
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,
});
},
};
};
9 changes: 9 additions & 0 deletions site/src/components/Dialog/ConfirmDialog/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>`. 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;
}

/**
Expand All @@ -66,6 +73,7 @@ export const ConfirmDialog: FC<ConfirmDialogProps> = ({
disabled = false,
hideCancel,
onClose,
onCloseAutoFocus,
onConfirm,
open = false,
title,
Expand All @@ -88,6 +96,7 @@ export const ConfirmDialog: FC<ConfirmDialogProps> = ({
<DialogContent
variant={type === "delete" ? "destructive" : "default"}
data-testid="dialog"
onCloseAutoFocus={onCloseAutoFocus}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
Expand Down
Loading
Loading