Skip to content

Commit b38df80

Browse files
authored
Guide users to manual OAuth registration when automatic (DCR) is rejected (#1176)
* Show the DCR rejection reason as an inline card, not a toast When transparent Dynamic Client Registration is rejected with an actionable reason (e.g. a server that refuses our redirect URI), surface the message as an inline error card on the bring-your-own-app recovery view instead of a transient toast, so the reason stays visible while the user registers an app. * Lead with manual entry when a server rejects automatic registration After transparent DCR is rejected (e.g. a server that only approves loopback redirect URIs), the recovery "Register an OAuth app" form still led with "Register automatically", sending the user straight back into the path that just failed. Pass the rejection reason into OAuthClientForm so it suppresses the auto path, restates why, and leads with manual client id/secret entry plus the callback URL to allow-list. * Label the rejection-recovery CTA "Manually register an app"
1 parent edc4411 commit b38df80

2 files changed

Lines changed: 51 additions & 8 deletions

File tree

packages/react/src/components/add-account-modal.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,10 @@ function AddAccountModalView(props: AddAccountModalProps) {
751751
const [dcrBusy, setDcrBusy] = useState(false);
752752
const [dcrFailed, setDcrFailed] = useState(false);
753753
const [oauthFallbackProbe, setOAuthFallbackProbe] = useState<DcrProbeResult | null>(null);
754+
// When transparent DCR is rejected with an actionable reason (e.g. the server
755+
// refuses our redirect URI), surface it as an inline error card on the
756+
// bring-your-own-app recovery view instead of a transient toast.
757+
const [dcrFallbackMessage, setDcrFallbackMessage] = useState<string | null>(null);
754758
// FIX 3 escape hatch: when no registered app matched the integration's
755759
// endpoints, the unmatched apps are collapsed behind an opt-in expander.
756760
const [showOtherApps, setShowOtherApps] = useState(false);
@@ -817,6 +821,7 @@ function AddAccountModalView(props: AddAccountModalProps) {
817821
setPickedApp(null);
818822
setOAuthFallbackProbe(null);
819823
setDcrFailed(false);
824+
setDcrFallbackMessage(null);
820825
}, [initialState, allMethods, defaultOwner, ownerOptions]);
821826

822827
useEffect(() => {
@@ -1004,6 +1009,7 @@ function AddAccountModalView(props: AddAccountModalProps) {
10041009
setOnePasswordItemId("");
10051010
setDcrFailed(false);
10061011
setOAuthFallbackProbe(null);
1012+
setDcrFallbackMessage(null);
10071013
};
10081014

10091015
// A just-created custom method joins the in-session list and is auto-selected
@@ -1267,10 +1273,10 @@ function AddAccountModalView(props: AddAccountModalProps) {
12671273
if (outcome.kind === "fallback") {
12681274
setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
12691275
setDcrFailed(true);
1270-
toast.error(
1271-
("message" in outcome ? outcome.message : undefined) ??
1272-
"Automatic setup unavailable. Register an app instead.",
1273-
);
1276+
// Surface the server's actionable rejection reason on the recovery view as
1277+
// an inline error card. Generic fallbacks (no message) fall through to the
1278+
// "register an app" empty state, which already guides the user.
1279+
setDcrFallbackMessage("message" in outcome ? (outcome.message ?? null) : null);
12741280
}
12751281
};
12761282

@@ -1342,6 +1348,7 @@ function AddAccountModalView(props: AddAccountModalProps) {
13421348
existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
13431349
String(app.slug),
13441350
)}
1351+
autoRegisterRejectedReason={dcrFallbackMessage}
13451352
prefill={{
13461353
authorizationUrl:
13471354
oauthHandoffPrefill?.authorizationUrl ??
@@ -1511,6 +1518,22 @@ function AddAccountModalView(props: AddAccountModalProps) {
15111518
<p className="text-xs text-muted-foreground">Loading OAuth apps…</p>
15121519
) : (
15131520
<div className="space-y-3">
1521+
{dcrFallbackMessage ? (
1522+
<div
1523+
role="alert"
1524+
className="space-y-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-3"
1525+
>
1526+
<p className="text-sm font-medium text-destructive">
1527+
Couldn&apos;t set up {integrationName} automatically
1528+
</p>
1529+
<p className="text-xs text-muted-foreground">
1530+
{dcrFallbackMessage}
1531+
</p>
1532+
<p className="text-xs text-muted-foreground">
1533+
Register an app below to connect.
1534+
</p>
1535+
</div>
1536+
) : null}
15141537
{/* No registered app matched the integration's endpoint:
15151538
empty state + a prominent register CTA, and an opt-in
15161539
collapsed "use a different registered app" escape hatch. */}
@@ -1529,7 +1552,9 @@ function AddAccountModalView(props: AddAccountModalProps) {
15291552
size="sm"
15301553
onClick={() => setRegisteringOAuthClient(true)}
15311554
>
1532-
Register app
1555+
{dcrFallbackMessage
1556+
? "Manually register an app"
1557+
: "Register app"}
15331558
</Button>
15341559
{oauthOtherApps.length > 0 && !showOtherApps ? (
15351560
<Button

packages/react/src/components/oauth-client-form.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export function OAuthClientForm(props: {
8888
readonly onCreated: (result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => void;
8989
readonly onCancel?: () => void;
9090
readonly surface?: "card" | "plain";
91+
/** When set, the server's automatic (DCR) registration is known to be rejected
92+
* for this host (e.g. it refused our redirect URI). The form then suppresses
93+
* the "Register automatically" path and leads with manual client entry; the
94+
* string is the actionable reason shown to the user. */
95+
readonly autoRegisterRejectedReason?: string | null;
9196
}) {
9297
const {
9398
integrationName,
@@ -98,6 +103,7 @@ export function OAuthClientForm(props: {
98103
onCreated,
99104
onCancel,
100105
surface = "card",
106+
autoRegisterRejectedReason = null,
101107
} = props;
102108
// Non-org hosts (local/desktop) have one local workspace. Offer only Local,
103109
// so the owner dropdown (which hides on a single option) disappears.
@@ -178,6 +184,11 @@ export function OAuthClientForm(props: {
178184
tokenUrl.trim().length > 0 &&
179185
grant === "authorization_code";
180186

187+
// When the server already rejected automatic registration for this host (e.g.
188+
// it refused our non-loopback redirect URI), don't lead the user back into the
189+
// path that just failed: suppress the auto CTA and lead with manual entry.
190+
const showAutoRegister = canRegisterDynamic && autoRegisterRejectedReason === null;
191+
181192
const handleDiscover = async () => {
182193
const url = issuerUrl.trim();
183194
if (url.length === 0) {
@@ -283,12 +294,19 @@ export function OAuthClientForm(props: {
283294
<div className="space-y-1">
284295
<p className="text-sm font-medium">Register an OAuth app</p>
285296
<p className="text-xs text-muted-foreground">
286-
{canRegisterDynamic
297+
{showAutoRegister
287298
? "Register automatically below, or enter a client id/secret manually."
288299
: "Paste a client id/secret. We only ask for endpoints when they aren't already known."}
289300
</p>
290301
</div>
291302

303+
{autoRegisterRejectedReason ? (
304+
<div className="space-y-1 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
305+
<p className="text-sm font-medium text-destructive">Automatic registration unavailable</p>
306+
<p className="text-xs text-muted-foreground">{autoRegisterRejectedReason}</p>
307+
</div>
308+
) : null}
309+
292310
{/* app name */}
293311
<div className="space-y-1.5">
294312
<Label htmlFor="oauth-app-name" className="text-xs text-muted-foreground">
@@ -305,7 +323,7 @@ export function OAuthClientForm(props: {
305323

306324
{/* register automatically (RFC 7591 DCR) — the primary path when the
307325
server advertises a registration endpoint: no client id/secret needed */}
308-
{canRegisterDynamic ? (
326+
{showAutoRegister ? (
309327
<div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 p-3">
310328
<p className="text-sm font-medium">No client ID needed</p>
311329
<p className="text-xs text-muted-foreground">
@@ -365,7 +383,7 @@ export function OAuthClientForm(props: {
365383
</div>
366384

367385
{/* divider before the manual (secondary) path when DCR is available */}
368-
{canRegisterDynamic ? (
386+
{showAutoRegister ? (
369387
<div className="flex items-center gap-2 text-xs text-muted-foreground">
370388
<span className="h-px flex-1 bg-border/60" />
371389
or enter a client ID manually

0 commit comments

Comments
 (0)