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

Skip to content

feat: expose dynamic client registration in deployment settings - #27480

Merged
BobbyHo merged 35 commits into
mainfrom
coder-eng-3062-dcr-flag-ui
Aug 3, 2026
Merged

feat: expose dynamic client registration in deployment settings#27480
BobbyHo merged 35 commits into
mainfrom
coder-eng-3062-dcr-flag-ui

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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 #27432

Where this sits in the request path

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)
Loading

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 feat!: add admin-controlled dynamic client registration toggle #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 onSuccessinvalidateQueries 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):
image

Enabling (confirmation dialog):

image

Enabled:

image

@linear-code

linear-code Bot commented Jul 24, 2026

Copy link
Copy Markdown

ENG-3062

@BobbyHo

BobbyHo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Warning

Superseded. This describes a switch, which has not existed since af8db900e8,
and the component has been rewritten several times since. Re-run against 3ededdcf9a:
#27480 (comment)

Backend verification

Manually verified against the local dev deployment (embedded Postgres + coderd)
that toggling the switch in the UI actually round-trips through the real
GET/PUT /api/v2/oauth2-provider/settings endpoints and persists to
site_configs, across three independent layers: the database, the API, and
the audit log.

Default state (disabled)

Postgres:

$ PGPASSWORD="$(cat .coderv2/postgres/password)" \
  psql -h localhost -p "$(sed -n '4p' .coderv2/postgres/data/postmaster.pid)" -U coder -d coder \
  -c "SELECT key, value FROM site_configs WHERE key = 'oauth2_dcr_enabled';"

        key         | value
--------------------+-------
 oauth2_dcr_enabled | false
(1 row)

API:

$ TOKEN=$(cat .coderv2/session)
$ curl -s http://127.0.0.1:3000/api/v2/oauth2-provider/settings \
  -H "Coder-Session-Token: $TOKEN"

{"dynamic_client_registration_enabled":false}

After enabling via the UI (clicked the switch, confirmed the warning dialog)

Audit log (GET /api/v2/audit?q=resource_type:oauth2_provider_settings):

{
    "id": "330e1fc8-046b-43de-b658-6b293a7a6c54",
    "time": "2026-07-23T16:58:06.761606-05:00",
    "resource_type": "oauth2_provider_settings",
    "action": "write",
    "diff": {
        "dynamic_client_registration_enabled": {
            "old": false,
            "new": true,
            "secret": false
        }
    },
    "status_code": 200,
    "description": "{user} updated oauth2 provider settings {target}",
    "user": {
        "username": "admin",
        "roles": [{ "name": "owner", "display_name": "Owner" }]
    }
}

Postgres:

        key         | value
--------------------+-------
 oauth2_dcr_enabled | true
(1 row)

API:

$ curl -s http://127.0.0.1:3000/api/v2/oauth2-provider/settings \
  -H "Coder-Session-Token: $TOKEN"

{"dynamic_client_registration_enabled":true}

After disabling via the UI (no confirmation dialog, disable is immediate)

Audit log:

{
    "id": "1f702c97-a2e7-4f4e-afaf-f99cd4ced4b8",
    "time": "2026-07-23T16:58:19.810326-05:00",
    "resource_type": "oauth2_provider_settings",
    "action": "write",
    "diff": {
        "dynamic_client_registration_enabled": {
            "old": true,
            "new": false,
            "secret": false
        }
    },
    "status_code": 200,
    "description": "{user} updated oauth2 provider settings {target}",
    "user": {
        "username": "admin",
        "roles": [{ "name": "owner", "display_name": "Owner" }]
    }
}

Postgres:

        key         | value
--------------------+-------
 oauth2_dcr_enabled | false
(1 row)

API:

{"dynamic_client_registration_enabled":false}

The enable (16:58:06, false → true) and disable (16:58:19, true → false)
audit entries are 13 seconds apart and in the correct order, matching the
enable-then-disable sequence performed in the UI. Each transition is
independently confirmed at the database, API, and audit layers.

@BobbyHo
BobbyHo force-pushed the coder-eng-3062-dcr-flag-ui branch 2 times, most recently from a660eda to 46351d8 Compare July 27, 2026 18:18
@BobbyHo
BobbyHo requested a review from Emyrk July 27, 2026 18:34
@BobbyHo
BobbyHo marked this pull request as ready for review July 27, 2026 18:34
@coderagents

coderagents Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

Updates Needed

  • docs/admin/integrations/oauth2-provider.md - Addressed in a49c0cd1a4, completed in c0132af971. The ## Dynamic Client Registration section leads with the web UI route (Deployment Settings > OAuth2 Applications > Settings tab > Enable / Disable) ahead of the CLI and API routes, states that enabling asks for confirmation while disabling does not, and covers the ?tab=settings deep link plus the view/edit permission split.

  • docs/admin/integrations/oauth2-provider.md - Addressed in c0132af971. New ### Delete an Application section documents the removal path: web UI via the Applications tab, the deleteOAuth2App permission requirement, and DELETE /api/v2/oauth2-provider/apps/$APP_ID. It separates deletion from token revocation.

  • docs/admin/integrations/oauth2-provider.md - Addressed in c0132af971. ### Method 1: Web UI step 2 now matches the Add application button and notes the Applications tab. The same commit replaced the separators with > per docs/.style/style-guide/formatting.md:59.

  • docs/admin/integrations/oauth2-provider.md - The DCR section says "Check or change the setting with the CLI:" and then lists only coder oauth2-provider dcr enable and coder oauth2-provider dcr disable. There is no CLI way to check the setting. oauth2ProviderDCR has exactly two children and its own handler just prints help (cli/oauth2provider.go, confirmed by cli/testdata/coder_oauth2-provider_dcr_--help.golden). Both subcommands call PutOAuth2ProviderSettings; neither reads. Reading is API-only, via the GET already shown just below.

    • Suggested fix: change the lead-in to "Change the setting with the CLI:" and let the existing GET /api/v2/oauth2-provider/settings example carry the read path.
    • Predates this PR (arrived with feat!: add admin-controlled dynamic client registration toggle #27316), but cecf2bcb1e repeats the claim in the UI: the offline/empty state in the Settings tab still tells the admin to "check the setting with coder oauth2-provider dcr" (OAuth2AppsSettingsPageView.tsx:94), which prints a help screen. Same wrong claim, worth fixing in both places.
  • docs/admin/integrations/oauth2-provider.md - New, and it corrects guidance I suggested. The last line of ### Delete an Application says deletion "is also how you remove clients that registered themselves while dynamic client registration was enabled." That is true about the mechanism but not actionable: an admin cannot tell which applications self-registered. The database has dynamically_registered (coderd/database/models.go:5530), but codersdk.OAuth2ProviderApp exposes only id, name, callback_url, icon, and endpoints, so neither the API response nor the Applications table carries the flag. Following the sentence means guessing, and a wrong guess deletes a live integration.

    • Cheapest honest fix: say that self-registered clients appear in the same list and are not distinguished from admin-created ones, so identify the client before deleting it.
    • Independently reached by coder-agents-review as CRF-46, which also notes deletion happens on the application's own page rather than on the tab itself. The current wording ("select the application on the Applications tab, then select Delete") reads correctly as a sequence, but naming the app page would remove the ambiguity.

Automated review via Coder Agents

@BobbyHo
BobbyHo marked this pull request as draft July 28, 2026 16:46
@BobbyHo
BobbyHo force-pushed the coder-eng-3062-dcr-flag-ui branch from 130b9ae to b984a8c Compare July 28, 2026 16:58
tracyjohnsonux added a commit that referenced this pull request Jul 28, 2026
Experimental / visual-only mock built on top of the pagination change
in this PR. Splits the OAuth2 Applications page into Applications and
Settings tabs (matching the GroupPage pattern) and prototypes the
Dynamic Client Registration setting from #27480 as an Enable button
with a destructive confirmation dialog and a green Enabled badge.

Not intended to merge as-is. Wired to local component state, not the
real oauth2-provider/settings query/mutation.
Base automatically changed from coder-eng-3056-dcr-flag to main July 28, 2026 23:59
@BobbyHo
BobbyHo force-pushed the coder-eng-3062-dcr-flag-ui branch from 948d3ec to aa69a4c Compare July 29, 2026 00:15
@BobbyHo
BobbyHo marked this pull request as ready for review July 29, 2026 00:27
@jakehwll

Copy link
Copy Markdown
Contributor

🤖 This comment was written by Coder Agents on behalf of Jake Howell.

One more frontend thing worth a look — the settings-tab visibility gate:

const canViewSettings = dynamicClientRegistrationEnabled !== undefined;

dynamicClientRegistrationEnabled is a single boolean | undefined, and undefined is being overloaded to mean three different things:

Situation viewDeploymentConfig? Query state value Tab shown? Intended?
No permission no disabled, never runs undefined hidden ✅ yes
Admin, first paint yes loading undefined hidden ❌ no — pops in on resolve
Admin, fetch failed yes error undefined hidden ❌ no — tab vanishes, error only shows in the top-level ErrorAlert with no context

The gate only wants to express row 1 ("does this user have permission to see settings"), but because it keys off the data rather than the permission, it also catches rows 2 and 3. The visible symptom is row 2: an admin loads the page, sees only the Applications tab for a beat, then Settings appears once the query resolves — tabs shifting after mount reads as a bug.

Root cause: the tab's existence is derived from async data that arrives later than the permission, which the page already knows synchronously on mount (permissions.viewDeploymentConfig). We're gating a stable, known-up-front fact on a slow, changing one.

Suggested fix — split the two concerns.

Page passes permission and value as separate props:

// OAuth2AppsSettingsPage.tsx
<OAuth2AppsSettingsPageView
  // ...
  canViewSettings={permissions.viewDeploymentConfig}
  canEditSettings={permissions.editDeploymentConfig}
  dynamicClientRegistrationEnabled={
    settingsQuery.data?.dynamic_client_registration_enabled
  }
  settingsLoading={settingsQuery.isLoading}
/>

View gates the tab on the permission, not the data:

{canViewSettings && <TabsTrigger value="settings">Settings</TabsTrigger>}

Then the undefined value is handled inside the Settings tab as a genuine loading state, instead of being smuggled through the tab gate:

{canViewSettings && (
  <TabsContent value="settings" className="pt-6">
    {dynamicClientRegistrationEnabled === undefined ? (
      <Loader />
    ) : (
      <DynamicClientRegistrationSetting
        enabled={dynamicClientRegistrationEnabled}
        canEdit={canEditSettings}
        onChange={onDynamicClientRegistrationChange}
      />
    )}
  </TabsContent>
)}

What this buys us:

  • The Settings tab is present from first paint for anyone with viewDeploymentConfig — no layout shift (fixes row 2).
  • A failed settings fetch keeps the tab in place (fixes row 3); the error can even render inside the tab where it's contextual.
  • DynamicClientRegistrationSetting still receives a clean enabled: boolean, so extracting it into its own file (per my other comments) stays trivial.
  • The // an undefined value means the settings tab has nothing to show comment goes away — the code now says what it means.

One thing to decide: whether an admin whose settings fetch fails should see a disabled-but-present control or an inline error. Either's fine; the point is the tab shouldn't disappear on them.

BobbyHo and others added 3 commits July 29, 2026 13:23
…nt settings

Surfaces the admin-controlled DCR setting from GET/PUT
/api/v2/oauth2-provider/settings (added in #27316) on the OAuth2
Applications deployment settings page. Enabling the switch requires
confirming a warning dialog, since it lets any client self-register
against the deployment per RFC 7591; disabling is immediate.
Adds coverage for the get/put oauth2-provider/settings client methods
added in api.ts (correct URL, payload, and error propagation), and for
the matching React Query helpers in queries/oauth2.ts (queryKey shape,
mutationFn delegation, and that a successful update invalidates the
settings query so the switch's on-screen state catches up).
Applies Tracy's mockup from tj/oauth2-apps-pagination. The page is now
tabbed (Applications | Settings) so future OAuth2 settings have a home,
and the DCR control moves off a Switch.

A switch implies an immediate on/off flip, which reads wrong when a
confirmation dialog sits in front of it. The setting is now a titled
section with a description, an 'Enabled' badge as a persistent state
indicator, and an Enable/Disable button. A button carries a confirmation
step without misrepresenting its own cost.

Enabling still confirms through a destructive-variant dialog; disabling
stays immediate. The Settings tab is hidden when the settings query is
skipped for users without viewDeploymentConfig.

Excludes the pagination work that shares the mockup branch.

Co-authored-by: Tracy Johnson <[email protected]>
@BobbyHo
BobbyHo force-pushed the coder-eng-3062-dcr-flag-ui branch from aa69a4c to af8db90 Compare July 29, 2026 20:34
BobbyHo added 4 commits July 31, 2026 09:41
The view took eleven props, seven of them for one boolean setting, and
the caller had to keep them mutually consistent by hand. A viewer who
could not read deployment config was a boolean the caller cross-checked
against five other values, so combinations the page can never produce
still typechecked and rendered: no view permission alongside a defined
value and an edit permission, or loading alongside a defined value.

The settings values now travel as one optional object, so "cannot view"
is the absence of the prop rather than a flag beside the values it
governs. Those combinations are unrepresentable rather than merely
undocumented, which matters more once a second setting lands on this
tab. The guard on the settings TabsContent is back, but as the narrowing
TypeScript requires rather than a permission check that could not fire.

The header sits outside the tabs and rendered its action unconditionally,
so "Add application" appeared while the settings tab was open, promising
to act on the settings below it and then navigating away. Gate it on the
active tab, and widen the description, which described only the
applications half. The title stays as it is, since changing it would
also mean changing the sidebar label and the browser title.
`invalidateQueries` matches by key prefix, so asserting the settings key
was invalidated said nothing about what else went with it. Seed an app
query alongside it and assert it survives. Widening the invalidation to
the `oauth2-provider` prefix, which would refetch every app on every
settings save, previously passed this suite untouched.

Keep the literal key assertion rather than treating it as redundant. The
two guard different drift: the literal assertion catches a change to the
key definition, and the new seed catches a change to the invalidation's
scope. Neither subsumes the other.

Import the shared `createTestQueryClient` and drop the local copy, which
matched it except for `gcTime`, giving this suite different cache
eviction semantics than every suite using the shared helper for no
stated reason.
Six small items from review, each independent:

The section heading was an `h3` directly under the page `h1`, skipping a
level; the repo's own section primitive uses `h2` in this position and the
class list already sets the visual size, so nothing moves.

`this.axios.get` and `.put` without a type parameter left `resp.data` as
`any`, which the declared return type then laundered. Both are typed now.

`hideCancel={false}` restated the `delete` default.

The confirm button already carries `data-testid="confirm-button"`, which
is what it exists for: the button and the trigger behind it share an
accessible name. Using it removes the scoping through the dialog node and
the comment explaining why the scope was needed.

`UnpermittedTabFromUrlFallsBack` asserted which tab was highlighted but
not that the settings control was absent, so a later `forceMount` on
inactive tab content would not have failed it.

The story title dropped the owning page directory, unlike sibling
components nested in a page folder, so it did not group with the view it
belongs to.
Every other factory in the oauth2 queries module drops the transport
prefix the module name already carries: getApps, getApp, postApp, putApp,
getAppSecrets, revokeApp, getGitHubDevice. The settings pair was
byte-identical to the API methods it wraps, so a call site could not tell
whether it held a query descriptor or a promise. The test paid for it
directly, holding both one qualifier apart in one file.

The key inverted the same rule the other way. The app keys carry the
prefix and derive the nested ones off the parent, while the settings key
retyped the "oauth2-provider" literal and named nothing about what it
keyed. Both now derive from a shared prefix constant, so the literal is
written once.

Two names that read as their opposite: the story exercising the
click-to-disable path sat one character from the disabled-state story, and
the view's unqualified isLoading was the apps query while the settings
object carries its own.

Six comments restated the code beneath them before reaching the fact a
reader could not get from the source. Two of those were added later in
this branch, not in the original diff.
@BobbyHo

BobbyHo commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 is blocked before review, and the reason is narrow: three findings from round 1 got no response of any kind, no fix, no reply, no ticket. Everything else moved.

The code work is substantial. 28 of 34 findings have a commit behind them and a reply naming it, including all three P2s and every P3. Three are deferred with Linear tickets and a stated reason (ENG-3116 for distinguishing self-registered clients, ENG-3118 for the shared badge and header primitives). Two replies pushed back with evidence rather than complying: the page title stays "OAuth2 applications" because widening it would drag the sidebar label and pageTitle() with it, and the SettingsHeaderTitle className override does work, correcting what the review said about it. Both are the right kind of answer.

The three silent items are the block:

  • CRF-32 (P2). The "Backend verification" comment still says it verified "toggling the switch in the UI", and its section headers still read "clicked the switch". There is no switch. af8db900e8 deleted it, and since then the component has been rewritten again: one button instead of two, aria-disabled for the in-flight state, a grouped settings prop, and a different dialog import. The comment is the only end-to-end evidence on this PR that the control writes to site_configs, and it describes a UI that has not existed for twelve commits. Re-run the three-layer check against 3ededdcf9a and post the result, or edit the comment to say the UI path is unverified since af8db900e8. Either resolves it; leaving stale evidence in place as if it were current does not.
  • CRF-34 (Nit). Commit 5a726d6ebc still has an empty body, and it still contains the tab-gate architecture change alongside the loading states its subject describes.
  • CRF-35 (Nit). The title still says "toggle" for a control that is deliberately not one, and at 74 characters it is still over 72.

Nits are fair to decline. Declining is a reply. "I'm not doing this because X" closes CRF-34 and CRF-35 in one sentence each, and the round unblocks.

No reviewers ran this round, so nothing in the new code has been reviewed. That matters more than usual here: the head moved twelve commits plus a merge of main, production additions went from 226 to 296, and six of the ten files were rewritten rather than patched. "Author fixed" in the inventory means the author says so; the panel has not seen SettingsTab, the four-state settings tab, the aria-disabled handling, or the eleven new stories. The next non-blocked round is a full panel pass over that code, not a spot check of the diffs against the old findings. Expect new findings in the fixes.

One correction the review owes you, unprompted by anything you wrote: CRF-14 told you to hand-roll a radix dialog, and by the time you got there the upstream rename had landed in your base and #/components/Dialog/ConfirmDialog/ConfirmDialog was simply the right import. The finding was right about the call site and wrong about the work. 754b1a6211 did the better thing.

🤖 This review was automatically generated with Coder Agents.

@BobbyHo

BobbyHo commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Backend verification, re-run against 3ededdcf9a

Replaces the verification in #27480 (comment), which described a switch. There has been no switch since af8db900e8, and the component has been rewritten several times since then. That comment should not be read as current evidence.

What was running. The frontend under test is 3ededdcf9a, served by the Vite dev server. The coderd binary was built at 754b1a6211, four commits behind. Every commit in 754b1a6211..3ededdcf9a touches only site/ or docs/, so the endpoints and audit behaviour are byte-identical; what changed, and what is being re-verified, is the UI that drives them.

The control today. One button rather than two, and not a switch. Enabling opens a confirmation dialog; disabling applies immediately with no dialog. In flight the button takes aria-disabled rather than the native attribute, so focus stays put.

Default state

$ psql ... -c "SELECT key, value FROM site_configs WHERE key = 'oauth2_dcr_enabled';"
        key         | value
--------------------+-------
 oauth2_dcr_enabled | false

$ curl -s http://127.0.0.1:3000/api/v2/oauth2-provider/settings -H "Coder-Session-Token: $TOKEN"
{"dynamic_client_registration_enabled":false}

After enabling in the UI (clicked Enable, confirmed the dialog)

Postgres:

        key         | value
--------------------+-------
 oauth2_dcr_enabled | true

API:

{"dynamic_client_registration_enabled":true}

Audit log (GET /api/v2/audit?q=resource_type:oauth2_provider_settings):

{
  "time": "2026-07-31T21:28:45.690347-07:00",
  "resource_type": "oauth2_provider_settings",
  "action": "write",
  "diff": {
    "dynamic_client_registration_enabled": { "old": false, "new": true, "secret": false }
  },
  "status_code": 200,
  "description": "{user} updated oauth2 provider settings {target}",
  "user": { "username": "admin", "roles": ["owner"] }
}

After disabling in the UI (clicked Disable, no dialog)

Postgres:

        key         | value
--------------------+-------
 oauth2_dcr_enabled | false

API:

{"dynamic_client_registration_enabled":false}

Audit log:

{
  "time": "2026-07-31T21:36:13.192295-07:00",
  "resource_type": "oauth2_provider_settings",
  "action": "write",
  "diff": {
    "dynamic_client_registration_enabled": { "old": true, "new": false, "secret": false }
  },
  "status_code": 200,
  "user": { "username": "admin", "roles": ["owner"] }
}

All three layers agree in both directions, so the rewritten control still round-trips through the real GET/PUT /api/v2/oauth2-provider/settings endpoints and persists to site_configs, with an audit row per write.

@BobbyHo BobbyHo changed the title feat: add OAuth2 dynamic client registration toggle to deployment settings feat: expose dynamic client registration in deployment settings Aug 1, 2026
@BobbyHo

BobbyHo commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

CRF-35 fixed. Retitled to feat: expose dynamic client registration in deployment settings, 63 characters and no "toggle". "Expose" rather than "add" because the setting already existed via the API and the CLI; this PR is what puts it in the UI.

CRF-34 declined, and the reason is that the artifact does not survive. coder/coder squash-merges, so 5a726d6ebc's body never reaches main; the permanent subject line is the PR title and the description. Amending it means rewriting pushed history to improve a message that is discarded at merge.

The finding is accurate on substance. That commit did move the tab gate off the data and onto permissions.viewDeploymentConfig, and its body is empty. Two things have happened since: 0560e77f88 removed the redundant permission check and 90292cd5cd replaced canViewSettings entirely with an optional settings prop, so the line the finding describes does not exist at head. The reasoning now lives in the PR description, which does survive the squash.

@BobbyHo

BobbyHo commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every one of the 28 fixes from the last round holds where a reviewer checked it, and reviewers checked by mutation rather than by reading. Kite deleted setIsEnableDialogOpen(false) from onConfirm and watched a story fail; Mafu-san collapsed the disabled/aria-disabled split into one attribute and watched three fail; Bisky reintroduced open={isEnableDialogOpen && !enabled} and watched SurvivesExternalEnabledChanges go red, which is the round-1 P2 verified rather than asserted. The boolean | undefined chain, the grouped settings prop, the RBAC parity, the dialog copy: all confirmed independently by four to six reviewers each. Leorio, who spent round 1 rewriting your dialog text, now cites it as the standard, and cited three of your commit bodies as the answer to anyone who argues bodies do not matter.

CRF-34 is closed. Five reviewers verified the squash-merge premise independently instead of taking it, and the defense holds on both legs: the body never reaches main, and the line the finding described was deleted by 90292cd5cd. The burden it moves onto the PR description is why there is a description item below.

4 P2, 14 P3, 1 P4, 10 Nits, 2 Notes. 23 panel reviewers plus a first pass.

The two that matter most are both about the settings tab reporting the wrong state, and they compound. CRF-39: putSettings throws away the PUT response and trusts a refetch, so a successful enable whose refetch fails renders the pre-save value under a red alert. CRF-40: load and save errors share one field and ?? prefers the older one. Chopper showed the fetch error persists until a fetch succeeds, and CRF-39 is exactly what produces that state, so after the first failed post-save refetch the tab reports success as failure and then masks every subsequent real failure. On a setting that opens an unauthenticated registration endpoint, the admin ends up unable to tell whether self-registration is on. I raised CRF-40 to P2 above the panel's unanimous P3 for that interaction; no single reviewer could see it from their own finding.

Hisoka, who went looking for something worse than a reopening dialog, found it in what the mutation chose not to keep: "The one place where the UI can confidently state the opposite of the security posture it just set."

One correction the panel owes you, and it reverses advice. Seven reviewers independently concluded that the Settings are unavailable. branch is unreachable because coderd always writes ptr.Ref(enabled), and three of them told you to delete the branch, the story, and the boolean | undefined type. That advice is wrong. Luffy alone suspected an offline path and flagged it as unverified. I verified it: isLoading is isPending && isFetching (queryObserver.js:304), and an offline query is fetchStatus: "paused", so isFetching is false. Probed against your pinned @tanstack/[email protected] with onlineManager.setOnline(false), the result is {status: "pending", fetchStatus: "paused", isLoading: false, error: null} with data undefined. An admin who opens the Settings tab offline lands on that branch with no spinner and no error. Keep it. Deleting it restores the blank tab CRF-3 was filed for, and Meruem's suggestion to throw in the queryFn cannot fire, because the queryFn never runs while paused. What the branch needs is the copy and the retry in CRF-44, not deletion.

Process items, none of which need code:

  • The PR description's sequence diagram still names props that 90292cd5cd deleted. Step 4 reads props: dynamicClientRegistrationEnabled, canEditSettings; at head the view takes one settings object and canEditSettings is a local in the page. The prose four paragraphs above describes the object correctly, so the description contradicts itself, and the suggested review order sends a reviewer to the diagram first. Three reviewers found this independently. Same edit: the description never says the tab is URL-backed, which matters more than usual now that CRF-34 is closed on the grounds that the description is the permanent record.
  • Two nits are pre-existing and adjacent, not yours. docs/admin/integrations/oauth2-provider.md:39 uses the same non-conforming navigation separator as your new line, and step 2 of the older web UI block says "Click Create Application" for a control labelled "Add application". Fixing yours and leaving those makes the file inconsistent in a way it was not before. Your call whether that is in scope.

Nothing below has a ticket. CRF-16, CRF-18, and CRF-19 stay deferred and were not re-evaluated.


site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx:101

P3 [CRF-43] The applications error alert renders outside <Tabs>, so an apps fetch failure sits directly above the DCR control on the Settings tab. (Chopper P3, Mafuuu P3, Nami P3, Razor P3, Melody P3, Kite Nit)

Six reviewers found this and three reproduced it with a throwaway story that sets an apps error, clicks the Settings tab, and asserts the alert and the Enable button are visible together. All three passed on the first run; all three stories were removed.

The asymmetry is the argument. 0560e77f88 moved settings errors inside the settings tab because an error must sit with the content it describes, and the header action got the same treatment with a comment at lines 84-86 saying a tab-specific element outside the tabs promises to act on content it navigates away from. The apps error is the same shape of element and kept its pre-tabs position. error is now explicitly apps-scoped: your own comment at OAuth2AppsSettingsPage.tsx:29-31 says so, and the view gates the applications empty state on it at line 127.

What the admin gets: the apps request fails, they open the Settings tab, and a red banner sits above a settings panel that loaded fine, at the moment they are deciding whether to open self-registration. Nami's reading is that they conclude the setting failed, and the actual failure one tab over is invisible from there. The inverse is equally wrong: an admin who never opens Applications is shown its failure anyway.

Razor stated the mitigation honestly: a real apps failure usually carries a server message that names applications, so the misattribution is partial. A network-level failure gives no attribution at all.

Fix: move the block inside <TabsContent value="applications">, above the <Table>. Kite named the cost of his own suggestion, which is that an admin sitting on Settings then gets no page-level signal that the apps list failed until they switch back, and argued it is the right tradeoff because the same reasoning already decided where settings.error goes. No story covers the pair today: WithError sets the apps error with settings present and never opens the Settings tab.

🤖

coderd/coderd.go:1267

P4 [CRF-56] /oauth2/register is unauthenticated and carries no rate limiter, so the button this PR adds turns on an endpoint any internet client can call in a loop. (Kurapika)

Not this PR's code. It is raised here because this PR is the first thing that puts that switch one click in front of an admin, so the human deciding whether to ship it should see the shape of what the click opens.

r.Post("/register", api.postOAuth2ClientRegistration()) sits under r.Route("/oauth2", ...) at coderd.go:1225, whose only middleware is RequireExperimentWithDevBypass. No apiKeyMiddleware. apiRateLimiter is applied at :1174, :1287, and :1515; /oauth2 is not among them. Each successful POST inserts a row into oauth2_provider_apps plus a secret row, with no per-caller cap and no cleanup path other than an admin deleting rows by hand, which CRF-46 shows is harder than the UI implies.

Kurapika checked whether this amplifies into CPU and it does not: generateClientCredentials reaches apikey.GenerateSecret, which hashes with SHA-256 rather than bcrypt, so this is unbounded storage growth by an unauthenticated caller, not the pbkdf2 exhaustion /login is rate limited against at coderd.go:1746. Lower ceiling, same missing fence.

One line at the route wraps /register in a group with httpmw.RateLimit. Cost: a legitimate client bursting registrations during a fleet rollout gets 429s and has to back off. P4 and out of this PR's scope; it needs a ticket rather than a fix here.

🤖

docs/admin/integrations/oauth2-provider.md:40

Note [CRF-68] Pre-existing, and now adjacent to your new block: the older web UI steps name a button that does not exist. (Razor)

Step 2 says "Click Create Application". The control is <span>Add application</span> at OAuth2AppsSettingsPageView.tsx:61, in both the header and the empty state. Outside this PR's diff and not its doing.

Reported because this diff adds a parallel numbered web UI list 33 lines below, so the two lists now disagree about how this page labels its actions, and a reader following the first one looks for a button that is not there. Same call as the navigation separator in CRF-62: fixing it is two words, and leaving it makes the new block the only accurate one.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread site/src/api/queries/oauth2.ts Outdated
Comment thread site/src/api/queries/oauth2.test.ts Outdated
BobbyHo added 11 commits August 1, 2026 10:57
The Dynamic Client Registration section tells an admin that clients which
already registered keep working until they are removed from the
Applications tab, and nothing in this file said how to remove one.
`Revoke Access` covers only token revocation, which ends sessions but
leaves the registration in place, so its heading read as if it covered
both. Add the deletion path, in the UI and through the API, and say
plainly that the two are different operations.

Reword the disable sentence, which sat under the UI steps and next to
"Enabling asks for confirmation" and so read as a claim that disabling
cuts off registered clients. The closing paragraph of the same section
says the opposite, and is the correct one.

Bring the touched blocks in line with the style guide: a greater-than
sign for navigation rather than an arrow, terminal periods on complete
sentences in ordered lists, one sentence per source line, and "select"
rather than "open". The navigation separator two lines above was already
wrong and is fixed too, since leaving it makes the file inconsistent
where it was not before.
`invalidateQueries` resolves whether or not the refetch that follows
succeeds, and a failed refetch keeps the query's last successful data
while setting an error. A save that returned 200 therefore rendered the
pre-save value under a red alert: no `Enabled` badge, a button still
reading Enable, and no success signal to contradict any of it. Of the two
directions this control drives, the one that misreported was the one that
opens an unauthenticated registration endpoint.

Write the response into the cache before invalidating, which is what
`putApp` three functions up already does. The invalidation stays, so the
freshness it buys is unchanged; what goes away is the window where a
second network failure inverts the displayed state.

The existing test could not see this: it called `onSuccess()` with no
argument, against a client with no observers, so nothing refetched. It
now passes the response through and asserts the cache holds it.
The two travelled in one field joined by `??`, which assumes they cannot
both be set. They can: a refetch that fails after a successful one keeps
the data and sets the error, and that error then stays until a fetch
succeeds. From that point every failed save was discarded in favour of the
older failure, so an admin whose PUT returned 403 because their role
changed kept reading an internal server error from minutes earlier, and
the actionable one never reached them.

They also need opposite treatment. A load failure means there is no value
to act on, so the control must not render. An update failure leaves the
value valid, so the control stays and the admin can retry. Merged, the
view could not ask which one it had, so it asked whether the value was
undefined and used that as a stand-in. That worked only because a load
failure happens to leave the value undefined.

Split them, and decide the four states in order in one place: loading,
then no value with or without a load error, then the value with the
update error winning the alert because it reports the action just taken.
Whether the control renders now follows from whether there is a value,
not from which error is set.

The `boolean | undefined` type stays. An offline query is
`fetchStatus: "paused"`, so `isLoading` is false with no data and no
error, and that branch is the only thing standing between an offline
admin and a blank tab.
Both regression stories asserted a state that only holds before a
`setTimeout` fires, with nothing ordering the timer after the assertions.
`KeepsFocusWhileUpdating` had 50ms to catch the in-flight state;
`SurvivesExternalEnabledChanges` had 150ms to open the dialog before the
external change landed. Makefile:943-947 records that this suite stalls
under CPU contention badly enough to hang browser imports, and
`make pre-push` runs it, so the headroom was not the reassurance it
looked like.

Both failures also blamed the wrong thing. One reported
`aria-disabled="false"`, which reads as a regression in the split between
the native attribute and the ARIA one. The other reported a missing
Enable button, which reads as the control disappearing. Neither names the
ordering that actually broke.

The harnesses now hand the transition to the story: one parks the pending
value and finishes on a click, the other applies the external change
directly. Both use `fireEvent` rather than `userEvent`, which matters
twice over: a pointer click would move focus off the button whose focus is
under test, and outside a modal dialog it would reach Radix's dismiss
layer and close the dialog the story is checking survived.

Asserting the dialog stayed open now reads `data-state`, which Radix flips
the moment something closes it, so the 400ms wait that existed to outlast
the animation is gone too. No wall-clock literal remains in the file, and
the one that was silently tied to a duration defined elsewhere is among
those removed.

Verified by inserting the stalls that made the old versions red, 60ms and
200ms: 11/11 green.
`ConfirmDialog` renders no `DialogTrigger`, so Radix has nothing to
restore focus to on close and it lands on `<body>`. A keyboard admin who
enabled DCR was returned to the top of the document, and their next Tab
started at the page chrome rather than the button whose label had just
changed. That is the same loss the `aria-disabled` handling exists to
prevent, on the one path that opens a dialog, and the existing focus
story never walked it because the disable path has no dialog.

Focusing from `onConfirm` and `onClose` does not hold. Radix moves focus
again when the exit animation ends, so a synchronous call is overwritten
a frame later. `onCloseAutoFocus` is the point Radix provides for this,
and `ConfirmDialog` did not forward it.

Add it as an optional passthrough. Nothing changes for the other call
sites unless they pass it, and preventing the default there covers every
way the dialog closes rather than needing a call in each handler.

Restoring focus for every `ConfirmDialog` rather than per caller is the
better repair, and it is tracked separately since it changes behaviour
for 45 call sites.
The applications error alert rendered outside `<Tabs>`, so an apps fetch
failure sat directly above the DCR control on the settings tab, at the
moment an admin is deciding whether to open self-registration. The
settings error moved inside its tab for this reason and the header action
was scoped to its tab for the same one; the apps error kept its pre-tabs
position. It now sits above the table it describes. `WithError` had no
play function at all and now walks both tabs.

Four assertions that were missing rather than wrong:

`Updating` asserted only that `onChange` was not called, which cannot see
the in-flight click guard disappear. `aria-disabled` does not stop a
keyboard Enter and the class only suppresses pointer affordance, so the
early return is the load-bearing half. In the disabled direction a
mid-flight Enter opens the dialog instead of calling `onChange`, so the
story now asserts the dialog stayed shut.

`EnableShowsConfirmationDialog` never asserted the dialog closes. Closing
was covered twice on the cancel path and nowhere on the path that opens
the endpoint, where a modal left standing would cover the badge reporting
the save worked.

`SettingsFetchErrorKeepsAppsEmptyState` never asserted the fallback copy
stays away, so nothing held the two mutually exclusive messages apart.

`CancelEnable` carried a comment explaining that its `onChange` assertion
proved nothing, immediately above that assertion. The assertion is gone
and the reasoning stays.

One comment pointed at a bug that existed on this branch before
595bc63. Squash-merge means that history never reaches `main`, so it
states the invariant instead.
An admin enabling this is opening a public, unauthenticated endpoint on a
feature the docs describe as experimental and not recommended for
production. The page said what enabling exposes but offered no way to
reach any of that, while eight sibling deployment settings pages carry a
docs link in the header. It goes beside the applications action rather
than replacing it: that action is scoped to its tab because it navigates
away, and a docs link is tab-agnostic, so it stays on both. The scoping
story now asserts that difference instead of only the disappearance.

The description also never named `/oauth2/register`. The feature exists
so clients can register themselves, and after confirming, an admin had a
green badge and nothing to hand anyone. The path appeared only in the
docs this PR wrote.

This is not a reversal of dropping the path from the dialog. That is a
decision point, where a fourth sentence and a raw path make the choice
harder to read. This is the description an admin returns to.
Both terminal states of the settings tab were dead ends. Retries are off,
refetch-on-focus is off, and the control that would trigger an invalidation
is the one that failed to render, so a reload was the only way out.

Wire a Retry button to the settings query's refetch, and name the cause when
the response omits the value rather than leaving the tab blank.
The container turns two RBAC permissions into the props that decide whether
an admin is offered a deployment-wide security switch, and nothing exercised
it. Wiring the button to the read permission instead of the update one kept
every story green while handing a view-only admin a control that 403s.

Add container stories for the three permission combinations, and a view story
for the in-flight prop a container story cannot produce. Each was checked
against a mutation of the code it covers.
The caveat told admins to remove already-registered clients from the
Applications tab. Deletion is not there, it is on an app's own page, and the
list renders only Name and Callback URL, both chosen by the registering
client, so a mixed list cannot be sorted by origin. Following the instruction
risks deleting an admin-created app and cascading its tokens.

State that disabling does not revoke, without naming a workflow that does not
exist yet. The wayfinding can return once apps carry an origin marker.
Rename the view's `error` prop to `appsError`. It was apps-scoped in behavior
and unscoped in name, which took a comment at the call site to say so; the
name now carries the scope its two siblings already do.

Spell the settings read permission once in the container. A disabled query
reports no loading and no data, so the query gate and the prop gate drifting
apart would strand the tab on its absent-value branch with nothing to explain
it.

Scope the second clause of the page description to viewers who have the
settings tab, matching how the tab-specific header action is already scoped.

Drop the queryKey literal assertion. It restated the constant and passed for
any key, and nothing invalidates the parent prefix for it to protect.
@BobbyHo

BobbyHo commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Round 3: the three findings with no thread to reply to

CRF-43, CRF-56, and CRF-68 were raised in the review body rather than on a line, so they have no thread. Answering them here. Everything else from round 3 now has a reply on its own thread.

CRF-43 (P3) — fixed. The applications error alert is inside <TabsContent value="applications">, above the table. An apps failure no longer renders over the settings panel.

Six reviewers found this and three noted their repro stories were removed. WithError now keeps one: it asserts the alert is visible on Applications, gone after switching to Settings, and back on return. Kite's stated cost is accepted deliberately, an admin sitting on Settings gets no page-level signal that the apps list failed until they switch back, for the reason Kite gave: the same reasoning already decided where the settings error goes.

CRF-68 (Note) — fixed. The older web UI steps said "Click Create Application"; the control is labelled "Add application". docs/admin/integrations/oauth2-provider.md:40 now reads "On the Applications tab, select Add application." Pre-existing and outside the diff, but taken because leaving it would have made the new block the only accurate list on the page.

CRF-56 (P4) — ticketed, not fixed here: ENG-3122.

Verified the claims against the code rather than taking them as read:

  • r.Post("/register", ...) at coderd.go:1267, inside the /oauth2 route at :1225, whose only middleware is RequireExperimentWithDevBypass. No apiKeyMiddleware. Confirmed.
  • apiRateLimiter is built at :1096 and applied at :1174, :1287, :1515. The /oauth2 route is a sibling of those groups, not nested in any of them, so it is genuinely uncovered. Confirmed.
  • No CPU amplification: apikey.GenerateSecret reaches HashSecret at apikey.go:154-157, a plain sha256.Sum256. Confirmed, and it is the right distinction to draw against /login.

Out of scope agreed, and the framing is right that this PR is what puts the switch one click away. Worth recording alongside it that DCR is off by default and /oauth2 is gated behind the oauth2 experiment, which is not in ExperimentsSafe, so this is not reachable on a default deployment today.

Deferred with tickets

Findings Ticket
CRF-63, CRF-65, CRF-67 ENG-3120 — reshape the settings tab state before a second setting lands
CRF-57, CRF-58, CRF-60, CRF-61 ENG-3121 — frontend cleanups
CRF-56 ENG-3122 — rate limit /oauth2/register
CRF-16 ENG-3116 (from round 2, still deferred)
CRF-18, CRF-19 ENG-3118 (from round 2, still deferred)
CRF-45 (global fix) ENG-3119 — ConfirmDialog focus loss across all 45 call sites

CRF-63/65/67 went into one ticket because all three reshape the same type and doing them separately means migrating the same call sites twice.

@BobbyHo

BobbyHo commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

Copy link
Copy Markdown
Contributor

@BobbyHo ⛔ This review has reached its per-chat spend limit ($139.01 / $100.00). Further review rounds are paused.

To raise the limit and continue, comment:

/coder-agents-review set-spend-limit:150

This is a per-chat budget, separate from any account-level usage limit.

🤖 Managed by Coder Agents.

@BobbyHo

BobbyHo commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Thank you again for reviewing my PR, @jakehwll. I’ve finally worked through all the comments from the Coder agents. I addressed most of them and deferred a few to follow-up tickets.

When you have a chance, could you take one more quick look? I also manually tested the latest changes in my local dev environment, and the UI for enabling and disabling DCR is working properly.

I’m hoping to merge this on Monday (PST) so it can be included in the next release build on Tuesday. Thanks!

@BobbyHo
BobbyHo merged commit 4245e4e into main Aug 3, 2026
30 checks passed
@BobbyHo
BobbyHo deleted the coder-eng-3062-dcr-flag-ui branch August 3, 2026 15:29
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add OAuth2 dynamic client registration toggle to the deployment settings UI

3 participants