History

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"

Rhys Sullivan committed 4 months ago

Commit b38df801 parent edc44112 files · +51 −8Browse files

Unified · Split

2 files
  1. packages/react/src/components/add-account-modal.tsx +30 −5
  2. packages/react/src/components/oauth-client-form.tsx +21 −3
packages/react/src/components/add-account-modal.tsx+30−5
@@ -751,6 +751,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 750 unmodified lines
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) { · 60 unmodified lines
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) { · 181 unmodified lines
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) { · 257 unmodified lines
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) { · 65 unmodified lines
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) { · 163 unmodified lines
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) { · 12 unmodified lines
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+21−3
@@ -88,6 +88,11 @@ export function OAuthClientForm(props: { · 87 unmodified lines
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: { · 4 unmodified lines
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: { · 74 unmodified lines
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: { · 99 unmodified lines
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: { · 10 unmodified lines
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: { · 53 unmodified lines
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