feat: expose dynamic client registration in deployment settings (#27480)

Adds the admin-controlled OAuth2 Dynamic Client Registration setting
landed by #27316 (`GET`/`PUT /api/v2/oauth2-provider/settings`) to the
OAuth2 Applications deployment settings page, since it was previously
only reachable via the API or `coder oauth2-provider dcr
enable|disable`.

The page is now tabbed, **Applications** and **Settings**, so DCR has a
home that further OAuth2 settings can share (an Initial Access Token
setting is a likely next one). The active tab is backed by a `tab`
search param, so `?tab=settings` links straight to it, and an
unpermitted deep link falls back to **Applications** rather than
selecting nothing. On the Settings tab, DCR renders as a titled section
with a description, an `Enabled` badge when active, and an
Enable/Disable button.

Enabling opens a confirmation dialog, since it lets any OAuth2 client
self-register against the deployment without prior admin approval (RFC
7591). Disabling is immediate, no confirmation.

The control is a button rather than a switch on design feedback: a
switch reads as an immediate on/off flip, which conflicts with a
confirmation dialog standing in front of it, and it left the only
explanation of the risk inside a dialog that disappears. A button
carries the confirmation step without misrepresenting what a click
costs, the always-visible description explains the setting on the page,
and the `Enabled` badge gives the active state a persistent indicator.
The layout follows Tracy's mockup on `tj/oauth2-apps-pagination`; the
apps-table pagination work that shares that branch is deliberately not
included here.

Visibility and editability are gated on the same
`ResourceDeploymentConfig` RBAC checks the endpoint itself enforces
(`viewDeploymentConfig` / `editDeploymentConfig`), not a separate
hardcoded check. The view takes the settings values as one optional
`settings` prop, absent when the viewer lacks `viewDeploymentConfig`, so
"cannot view" is the shape of the prop rather than a flag the caller
keeps consistent with the values beside it, and the tab is not rendered
at all.

Closes https://github.com/coder/coder/issues/27432

## Where this sits in the request path

```mermaid
sequenceDiagram
    autonumber
    actor Admin
    participant View as OAuth2AppsSettingsPageView<br/>(Tabs + Enable/Disable + Dialog)
    participant Page as OAuth2AppsSettingsPage<br/>(React Query)
    participant S as coderd

    Note over Page: On mount
    Page->>S: GET /api/v2/oauth2-provider/settings
    S-->>Page: { dynamic_client_registration_enabled }
    Page-->>View: settings: { dynamicClientRegistrationEnabled, canEdit, ... }

    Note over Admin,View: Admin opens the Settings tab and enables DCR
    Admin->>View: click "Enable"
    View->>View: open confirmation dialog<br/>(no request sent yet)
    Admin->>View: click Confirm
    View->>Page: settings.onDynamicClientRegistrationChange(true)
    Page->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: true}
    S-->>Page: 200 OK (audited)
    Page->>S: GET /api/v2/oauth2-provider/settings (refetch)
    S-->>Page: { dynamic_client_registration_enabled: true }
    Page-->>View: section shows the "Enabled" badge and a Disable button

    Note over Admin,View: Admin disables DCR
    Admin->>View: click "Disable"
    View->>Page: onDynamicClientRegistrationChange(false)<br/>(no dialog, disable is immediate)
    Page->>S: PUT ... {dynamic_client_registration_enabled: false}
    S-->>Page: 200 OK (audited)
```

## Files changed

All 10 files are hand-written; nothing in this PR is `make gen` output.

| File | What changed |
|---|---|
| `site/src/api/api.ts` | New
`getOAuth2ProviderSettings`/`putOAuth2ProviderSettings` methods, thin
typed wrappers around the two endpoints #27316 added to `main`. |
| `site/src/api/api.test.ts` | Covers both methods against the request
they issue and the error they propagate. |
| `site/src/api/queries/oauth2.ts` | A `getSettings` query and a
`putSettings` mutation that invalidates the settings key on success.
Both the app and settings keys now derive from a shared
`oauth2ProviderKey` constant. |
| `site/src/api/queries/oauth2.test.ts` | 4 tests: the key nesting, both
delegations, and that a successful update invalidates the settings key
without touching app queries. |
| `.../OAuth2AppsSettingsPage.tsx` | Wires query and mutation into the
page and passes the settings values down as one object, or omits it
entirely without `viewDeploymentConfig`. The apps error stays its own
prop, since the view gates the applications empty state on it. |
| `.../OAuth2AppsSettingsPageView.tsx` | `Tabs` splitting Applications
from Settings. The settings tab distinguishes loading, failed, and a
value the server omitted rather than rendering nothing, and the header's
"Add application" action is scoped to the applications tab. |
| `.../OAuth2AppsSettingsPageView.stories.tsx` | 14 stories, covering
the tab wiring, both permission boundaries, the header action's scope,
and the settings tab's loading, fetch-error, update-error, and
value-omitted states. |
| `.../DynamicClientRegistrationSetting.tsx` | The section itself:
heading, description including what disabling does not undo, `Enabled`
badge, a permission explanation when the viewer cannot edit, and one
button that confirms only in the enable direction. |
| `.../DynamicClientRegistrationSetting.stories.tsx` | 11 stories,
including focus surviving an in-flight request and the dialog ignoring a
value that changes underneath it. |
| `docs/admin/integrations/oauth2-provider.md` | Adds the web UI route
to the DCR section, which previously enumerated only the CLI and the
management API. |

## Suggested review order

Follows the direction data actually flows, from the raw HTTP call up to
the rendered section.

1. **`site/src/api/api.ts`**: the two new methods. Confirms they match
the `codersdk.OAuth2ProviderSettings` shape #27316 landed and sit next
to the existing OAuth2 app methods they mirror.
2. **`site/src/api/queries/oauth2.ts`**: the query/mutation pair. The
mutation's `onSuccess` → `invalidateQueries` is the one detail worth
double-checking: it's what makes the on-screen state catch up with what
was just saved, rather than trusting the PUT payload.
3. **`OAuth2AppsSettingsPage.tsx`**: the container. Check the two
separate permission gates (`viewDeploymentConfig` on the query's
`enabled` option, `editDeploymentConfig` on the button's editability)
match the RBAC the backend enforces.
4. **`OAuth2AppsSettingsPageView.tsx`**: the tabs and the settings tab's
four states. The `settings` prop being optional is what hides the tab;
the error inside the tab is deliberately separate from the page-level
`error`, which gates the applications empty state.
5. **`DynamicClientRegistrationSetting.tsx`**: the section. Two things
worth reading closely: the enable path opens the dialog while the
disable path calls straight through, and lacking permission uses the
native `disabled` attribute while an in-flight request uses
`aria-disabled`, so a keyboard user is not blurred mid-flip.
6. **The two story files**: read last, as they exercise everything above
without a real server. The dialog stories query
`canvasElement.ownerDocument.body` rather than `canvasElement`, since
the dialog renders into a portal attached to `<body>`.

## Deliberately not in this PR

- **ENG-3116**: the applications list cannot distinguish self-registered
clients from admin-created ones. Surfacing that needs a new field on
`codersdk.OAuth2ProviderApp`, which is an API addition this PR does not
need.
- **ENG-3118**: reusing the shared `EnabledBadge` and `SettingsHeader`
primitives for this section. Both hinge on what the mockup intends, and
the badge in particular is a visible change either here or on the four
other pages that share it.

## Screenshots

Default (disabled):
<img width="1676" height="497" alt="image"
src="https://github.com/user-attachments/assets/cfa60266-8678-410e-9577-16ef474491e3"
/>



Enabling (confirmation dialog):

<img width="1661" height="558" alt="image"
src="https://github.com/user-attachments/assets/a7d54fdd-f65d-4fec-9ed9-3bfdcfdae5be"
/>



Enabled:

<img width="1666" height="559" alt="image"
src="https://github.com/user-attachments/assets/d39251c2-771c-4608-81c2-dda151b35c3d"
/>

---------

Co-authored-by: Tracy Johnson <tracy@coder.com>
This commit is contained in:
Bobby Ho
2026-08-03 08:29:35 -07:00
committed by GitHub
co-authored by Tracy Johnson
parent 497ab9e1a6
commit 4245e4e378
13 changed files with 1346 additions and 48 deletions
+36 -2
View File
@@ -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:
+49
View File
@@ -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
View File
@@ -2035,6 +2035,24 @@ class ApiMethods {
await this.axios.delete(`/oauth2/tokens?client_id=${appId}`);
};
getOAuth2ProviderSettings =
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> => {
+82
View File
@@ -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,
});
});
});
+26 -1
View File
@@ -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,
});
},
};
};
@@ -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;
}
/**
@@ -66,6 +73,7 @@ export const ConfirmDialog: FC<ConfirmDialogProps> = ({
disabled = false,
hideCancel,
onClose,
onCloseAutoFocus,
onConfirm,
open = false,
title,
@@ -88,6 +96,7 @@ export const ConfirmDialog: FC<ConfirmDialogProps> = ({
<DialogContent
variant={type === "delete" ? "destructive" : "default"}
data-testid="dialog"
onCloseAutoFocus={onCloseAutoFocus}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
@@ -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<typeof DynamicClientRegistrationSetting> = {
title:
"pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/DynamicClientRegistrationSetting",
component: DynamicClientRegistrationSetting,
args: {
enabled: false,
canEdit: true,
isUpdating: false,
onChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof DynamicClientRegistrationSetting>;
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<boolean | undefined>(undefined);
return (
<div className="flex flex-col gap-6">
{/*
* 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.
*/}
<button
type="button"
onClick={() => {
if (pending !== undefined) {
setEnabled(pending);
setPending(undefined);
setIsUpdating(false);
}
}}
>
Finish request
</button>
<DynamicClientRegistrationSetting
{...args}
enabled={enabled}
isUpdating={isUpdating}
onChange={(next) => {
setIsUpdating(true);
setPending(next);
}}
/>
</div>
);
},
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 <body>.
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 `<body>`, 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 (
<div className="flex flex-col gap-6">
<div className="flex flex-row gap-4">
<button type="button" onClick={() => setEnabled(true)}>
Enable externally
</button>
<button type="button" onClick={() => setEnabled(false)}>
Disable externally
</button>
</div>
<DynamicClientRegistrationSetting
{...args}
enabled={enabled}
onChange={(next) => {
setEnabled(next);
args.onChange(next);
}}
/>
</div>
);
},
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);
},
};
@@ -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<HTMLButtonElement>(null);
return (
<>
<section
aria-labelledby={headingId}
className="flex flex-row items-start justify-between gap-8"
>
<div className="flex flex-col gap-1 max-w-xl">
<div className="flex flex-row items-center gap-2">
<h2
id={headingId}
className="text-content-primary text-base font-semibold m-0"
>
Dynamic Client Registration
</h2>
{enabled && (
<Badge size="sm" variant="green" className="border-0 shadow-none">
Enabled
</Badge>
)}
</div>
{/*
* 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.
*/}
<p className="text-sm text-content-secondary m-0">
Allow OAuth2 clients to register themselves at{" "}
<code className="text-xs">/oauth2/register</code> without prior
administrator approval (RFC 7591). Disabling stops new
registrations. Clients that already registered keep working until an
administrator deletes them.
</p>
{/*
* 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 && (
<p className="text-sm text-content-secondary m-0 mt-1">
You need permission to edit deployment configuration to change
this setting.
</p>
)}
</div>
{/*
* 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.
*/}
<Button
ref={buttonRef}
variant={enabled ? "outline" : "default"}
disabled={!canEdit}
aria-disabled={isUpdating}
className="aria-disabled:pointer-events-none"
onClick={() => {
if (isUpdating) {
return;
}
if (enabled) {
onChange(false);
} else {
setIsEnableDialogOpen(true);
}
}}
>
<Spinner loading={isUpdating} aria-hidden />
{enabled ? "Disable" : "Enable"}
</Button>
</section>
<ConfirmDialog
type="delete"
open={isEnableDialogOpen}
onConfirm={() => {
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 <body>. 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."
/>
</>
);
};
@@ -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<typeof OAuth2AppsSettingsPage> = {
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<typeof OAuth2AppsSettingsPage>;
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();
},
};
@@ -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 = () => {
<OAuth2AppsSettingsPageView
apps={appsQuery.data}
isLoading={appsQuery.isLoading}
error={appsQuery.error}
isLoadingApps={appsQuery.isLoading}
appsError={appsQuery.error}
canCreateApp={canCreateApp}
settings={
canViewSettings
? {
canEdit: canEditSettings,
isLoading: settingsQuery.isLoading,
isUpdating: updateSettingsMutation.isPending,
loadError: settingsQuery.error,
onRetry: () => void settingsQuery.refetch(),
updateError: updateSettingsMutation.error,
dynamicClientRegistrationEnabled:
settingsQuery.data?.dynamic_client_registration_enabled,
onDynamicClientRegistrationChange: (enabled) => {
updateSettingsMutation.mutate({
dynamic_client_registration_enabled: enabled,
});
},
}
: undefined
}
/>
</>
);
@@ -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<typeof OAuth2AppsSettingsPageView> = {
title: "pages/DeploymentSettingsPage/OAuth2AppsSettingsPageView",
component: OAuth2AppsSettingsPageView,
args: {
canCreateApp: true,
settings: MockSettingsTab,
},
};
export default meta;
@@ -15,27 +31,42 @@ type Story = StoryObj<typeof OAuth2AppsSettingsPageView>;
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();
},
};
@@ -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 <Loader label="Loading settings" />;
}
if (settings.dynamicClientRegistrationEnabled === undefined) {
return (
<div className="flex flex-col items-start gap-4">
{settings.loadError ? (
<ErrorAlert error={settings.loadError} />
) : (
<p className="text-sm text-content-secondary m-0">
Coder did not return a value for Dynamic Client Registration. This
can happen while the browser is offline. Retry, or check the setting
with <code className="text-xs">coder oauth2-provider dcr</code>.
</p>
)}
<Button variant="outline" size="sm" onClick={settings.onRetry}>
Retry
</Button>
</div>
);
}
const alertError = settings.updateError ?? settings.loadError;
return (
<div className="flex flex-col gap-4">
{Boolean(alertError) && <ErrorAlert error={alertError} />}
<DynamicClientRegistrationSetting
enabled={settings.dynamicClientRegistrationEnabled}
canEdit={settings.canEdit}
isUpdating={settings.isUpdating}
onChange={settings.onDynamicClientRegistrationChange}
/>
</div>
);
};
const AddApplicationButton: FC = () => (
@@ -41,51 +126,101 @@ const AddApplicationButton: FC = () => (
const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
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 (
<div>
{/*
* 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.
*/}
<SettingsHeader
actions={canCreateApp ? <AddApplicationButton /> : undefined}
actions={
<>
{canCreateApp && activeTab === "applications" && (
<AddApplicationButton />
)}
<SettingsHeaderDocsLink
href={docs(
"/admin/integrations/oauth2-provider#dynamic-client-registration",
)}
/>
</>
}
>
<SettingsHeaderTitle>OAuth2 applications</SettingsHeaderTitle>
{/*
* 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.
*/}
<SettingsHeaderDescription>
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"}.
</SettingsHeaderDescription>
</SettingsHeader>
{Boolean(error) && (
<div className="mb-4">
<ErrorAlert error={error} />
</div>
)}
<Tabs value={activeTab} onValueChange={tabState.setValue}>
<TabsList>
<TabsTrigger value="applications">Applications</TabsTrigger>
{settings && <TabsTrigger value="settings">Settings</TabsTrigger>}
</TabsList>
<Table className="table-fixed" aria-label="OAuth2 applications">
<TableHeader>
<TableRow>
<TableHead className="w-1/3">Name</TableHead>
<TableHead className="w-1/3">Callback URL</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody size="lg">
{isLoading ? (
<TableLoader />
) : !error && (!apps || apps.length === 0) ? (
<TableEmpty
message="No OAuth2 applications configured"
description="Add an application to use Coder as an OAuth2 provider."
cta={canCreateApp ? <AddApplicationButton /> : undefined}
/>
) : (
apps?.map((app) => <OAuth2AppRow key={app.id} app={app} />)
<TabsContent value="applications" className="pt-6">
{/*
* 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) && (
<div className="mb-4">
<ErrorAlert error={appsError} />
</div>
)}
</TableBody>
</Table>
<Table className="table-fixed" aria-label="OAuth2 applications">
<TableHeader>
<TableRow>
<TableHead className="w-1/3">Name</TableHead>
<TableHead className="w-1/3">Callback URL</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody size="lg">
{isLoadingApps ? (
<TableLoader />
) : !appsError && (!apps || apps.length === 0) ? (
<TableEmpty
message="No OAuth2 applications configured"
description="Add an application to use Coder as an OAuth2 provider."
cta={canCreateApp ? <AddApplicationButton /> : undefined}
/>
) : (
apps?.map((app) => <OAuth2AppRow key={app.id} app={app} />)
)}
</TableBody>
</Table>
</TabsContent>
{settings && (
<TabsContent value="settings" className="pt-6">
<SettingsTabBody settings={settings} />
</TabsContent>
)}
</Tabs>
</div>
);
};
+4
View File
@@ -4867,6 +4867,10 @@ export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [
},
];
export const MockOAuth2ProviderSettings: TypesGen.OAuth2ProviderSettings = {
dynamic_client_registration_enabled: false,
};
export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] =
[
{