feat: expose dynamic client registration in deployment settings - #27480
Conversation
|
Warning Superseded. This describes a switch, which has not existed since Backend verificationManually verified against the local dev deployment (embedded Postgres + Default state (disabled)Postgres: API: After enabling via the UI (clicked the switch, confirmed the warning dialog)Audit log ( {
"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: API: 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: API: The enable ( |
a660eda to
46351d8
Compare
Documentation CheckUpdates Needed
Automated review via Coder Agents |
130b9ae to
b984a8c
Compare
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.
948d3ec to
aa69a4c
Compare
One more frontend thing worth a look — the settings-tab visibility gate: const canViewSettings = dynamicClientRegistrationEnabled !== undefined;
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 ( 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 {canViewSettings && (
<TabsContent value="settings" className="pt-6">
{dynamicClientRegistrationEnabled === undefined ? (
<Loader />
) : (
<DynamicClientRegistrationSetting
enabled={dynamicClientRegistrationEnabled}
canEdit={canEditSettings}
onChange={onDynamicClientRegistrationChange}
/>
)}
</TabsContent>
)}What this buys us:
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. |
…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]>
aa69a4c to
af8db90
Compare
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.
|
/coder-agents-review |
There was a problem hiding this comment.
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.
af8db900e8deleted it, and since then the component has been rewritten again: one button instead of two,aria-disabledfor the in-flight state, a groupedsettingsprop, and a different dialog import. The comment is the only end-to-end evidence on this PR that the control writes tosite_configs, and it describes a UI that has not existed for twelve commits. Re-run the three-layer check against3ededdcf9aand post the result, or edit the comment to say the UI path is unverified sinceaf8db900e8. Either resolves it; leaving stale evidence in place as if it were current does not. - CRF-34 (Nit). Commit
5a726d6ebcstill 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.
Backend verification, re-run against
|
|
CRF-35 fixed. Retitled to CRF-34 declined, and the reason is that the artifact does not survive. The finding is accurate on substance. That commit did move the tab gate off the data and onto |
|
/coder-agents-review |
There was a problem hiding this comment.
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
90292cd5cddeleted. Step 4 readsprops: dynamicClientRegistrationEnabled, canEditSettings; at head the view takes onesettingsobject andcanEditSettingsis 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:39uses 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.
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.
Round 3: the three findings with no thread to reply toCRF-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 Six reviewers found this and three noted their repro stories were removed. CRF-68 (Note) — fixed. The older web UI steps said "Click Create Application"; the control is labelled "Add application". CRF-56 (P4) — ticketed, not fixed here: ENG-3122. Verified the claims against the code rather than taking them as read:
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 Deferred with tickets
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. |
|
/coder-agents-review |
|
@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: This is a per-chat budget, separate from any account-level usage limit.
|
|
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! |
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 orcoder 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
tabsearch param, so?tab=settingslinks 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, anEnabledbadge 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
Enabledbadge gives the active state a persistent indicator. The layout follows Tracy's mockup ontj/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
ResourceDeploymentConfigRBAC checks the endpoint itself enforces (viewDeploymentConfig/editDeploymentConfig), not a separate hardcoded check. The view takes the settings values as one optionalsettingsprop, absent when the viewer lacksviewDeploymentConfig, 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)Files changed
All 10 files are hand-written; nothing in this PR is
make genoutput.site/src/api/api.tsgetOAuth2ProviderSettings/putOAuth2ProviderSettingsmethods, thin typed wrappers around the two endpoints #27316 added tomain.site/src/api/api.test.tssite/src/api/queries/oauth2.tsgetSettingsquery and aputSettingsmutation that invalidates the settings key on success. Both the app and settings keys now derive from a sharedoauth2ProviderKeyconstant.site/src/api/queries/oauth2.test.ts.../OAuth2AppsSettingsPage.tsxviewDeploymentConfig. The apps error stays its own prop, since the view gates the applications empty state on it..../OAuth2AppsSettingsPageView.tsxTabssplitting 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.../DynamicClientRegistrationSetting.tsxEnabledbadge, a permission explanation when the viewer cannot edit, and one button that confirms only in the enable direction..../DynamicClientRegistrationSetting.stories.tsxdocs/admin/integrations/oauth2-provider.mdSuggested review order
Follows the direction data actually flows, from the raw HTTP call up to the rendered section.
site/src/api/api.ts: the two new methods. Confirms they match thecodersdk.OAuth2ProviderSettingsshape feat!: add admin-controlled dynamic client registration toggle #27316 landed and sit next to the existing OAuth2 app methods they mirror.site/src/api/queries/oauth2.ts: the query/mutation pair. The mutation'sonSuccess→invalidateQueriesis 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.OAuth2AppsSettingsPage.tsx: the container. Check the two separate permission gates (viewDeploymentConfigon the query'senabledoption,editDeploymentConfigon the button's editability) match the RBAC the backend enforces.OAuth2AppsSettingsPageView.tsx: the tabs and the settings tab's four states. Thesettingsprop being optional is what hides the tab; the error inside the tab is deliberately separate from the page-levelerror, which gates the applications empty state.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 nativedisabledattribute while an in-flight request usesaria-disabled, so a keyboard user is not blurred mid-flip.canvasElement.ownerDocument.bodyrather thancanvasElement, since the dialog renders into a portal attached to<body>.Deliberately not in this PR
codersdk.OAuth2ProviderApp, which is an API addition this PR does not need.EnabledBadgeandSettingsHeaderprimitives 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):

Enabling (confirmation dialog):
Enabled: